Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5854de3c23 | ||
|
|
967442892a | ||
|
|
202d248ae4 | ||
|
|
f667417d84 | ||
|
|
750e6315dc | ||
|
|
f117b2df32 | ||
|
|
9631f083b8 | ||
|
|
c8a52b78e1 | ||
|
|
fe4946a269 | ||
|
|
3de9c2bda0 | ||
|
|
cee30c53b8 | ||
|
|
f22b5d9daa | ||
|
|
88f8753329 | ||
|
|
fe02a97fa1 | ||
|
|
a9172fd832 | ||
|
|
96a659ae5a | ||
|
|
13739dabd8 | ||
|
|
22acfbae28 | ||
|
|
adf50b2419 | ||
|
|
35022a0478 | ||
|
|
2838053ba8 | ||
|
|
66397a6626 | ||
|
|
dfe997dce2 | ||
|
|
163c7ff791 | ||
|
|
bd5e14b488 | ||
|
|
8cd16ee3dc | ||
|
|
57564381cb | ||
|
|
0207109612 |
@@ -23,6 +23,7 @@
|
||||
/packages/lib/getUserAvailability.ts @calcom/Foundation
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/atoms/**/*WebWrapper.tsx @calcom/Consumer
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
e2e:
|
||||
timeout-minutes: 20
|
||||
name: E2E API v2
|
||||
runs-on: buildjet-8vcpu-ubuntu-2204
|
||||
runs-on: buildjet-16vcpu-ubuntu-2204
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
@@ -222,6 +222,13 @@ async function handler(req: NextApiRequest) {
|
||||
};
|
||||
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
||||
|
||||
if (req.body.eventTypeId !== undefined && typeof req.body.eventTypeId !== "number") {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, eventTypeId must be a number",
|
||||
});
|
||||
}
|
||||
|
||||
if (isOrganizationOwnerOrAdmin) {
|
||||
const accessibleUsersIds = await getAccessibleUsers({
|
||||
adminUserId: userId,
|
||||
|
||||
@@ -25,6 +25,44 @@ vi.mock("@calcom/lib/server/i18n", () => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("POST /api/bookings - eventTypeId validation", () => {
|
||||
test("String eventTypeId should return 400", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
eventTypeId: "invalid-string",
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
expect(res._getStatusCode()).toBe(400);
|
||||
expect(JSON.parse(res._getData())).toEqual(
|
||||
expect.objectContaining({
|
||||
message: "Bad request, eventTypeId must be a number",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("Number eventTypeId should not trigger validation error", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {
|
||||
eventTypeId: 123,
|
||||
},
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
const statusCode = res._getStatusCode();
|
||||
const responseData = JSON.parse(res._getData());
|
||||
|
||||
if (statusCode === 400) {
|
||||
expect(responseData.message).not.toBe("Bad request, eventTypeId must be a number");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(true)("POST /api/bookings", () => {
|
||||
describe("Errors", () => {
|
||||
test("Missing required data", async () => {
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.257",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
@@ -30,7 +31,7 @@ import {
|
||||
GOOGLE_CALENDAR_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
|
||||
import { ICS_CALENDAR, ICS_CALENDAR_TYPE } from "@calcom/platform-constants/apps";
|
||||
import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
const CLIENT_REDIRECT_URI = "http://localhost:5555";
|
||||
@@ -247,6 +248,35 @@ describe("Platform Calendars Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it(`/GET/v2/calendars with access token`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.set("Origin", CLIENT_REDIRECT_URI)
|
||||
.expect(200);
|
||||
|
||||
const data: ConnectedCalendarsData = response.body.data;
|
||||
expect(data.connectedCalendars).toBeDefined();
|
||||
expect(data.connectedCalendars).toHaveLength(3);
|
||||
|
||||
const googleConnectedCalendar = data.connectedCalendars.find(
|
||||
(calendar) => calendar.integration.type === GOOGLE_CALENDAR_TYPE
|
||||
);
|
||||
const office365ConnectedCalendar = data.connectedCalendars.find(
|
||||
(calendar) => calendar.integration.type === OFFICE_365_CALENDAR_TYPE
|
||||
);
|
||||
const icsConnectedCalendar = data.connectedCalendars.find(
|
||||
(calendar) => calendar.integration.type === ICS_CALENDAR_TYPE
|
||||
);
|
||||
|
||||
expect(googleConnectedCalendar).toBeDefined();
|
||||
expect(office365ConnectedCalendar).toBeDefined();
|
||||
expect(icsConnectedCalendar).toBeDefined();
|
||||
|
||||
expect(data.destinationCalendar).toBeDefined();
|
||||
expect(data.destinationCalendar.integration).toEqual(GOOGLE_CALENDAR_TYPE);
|
||||
});
|
||||
|
||||
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
|
||||
const body = {
|
||||
id: 10,
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
export class GetManagedOrganizationsInput_2024_08_13 extends SkipTakePagination {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiProperty({ example: "organization-slug", description: "The slug of the managed organization" })
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
example: "metadata-key",
|
||||
description:
|
||||
"The key of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataValue",
|
||||
})
|
||||
metadataKey?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiProperty({
|
||||
example: "metadata-value",
|
||||
description:
|
||||
"The value of the metadata - it is case sensitive so provide exactly as stored. If you provide it then you must also provide metadataKey",
|
||||
})
|
||||
metadataValue?: string;
|
||||
}
|
||||
+29
-2
@@ -1,3 +1,4 @@
|
||||
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
@@ -24,6 +25,17 @@ export class ManagedOrganizationsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getManagedOrganizationBySlug(managerOrganizationId: number, managedOrganizationSlug: string) {
|
||||
return this.dbRead.prisma.managedOrganization.findFirst({
|
||||
where: {
|
||||
managerOrganizationId,
|
||||
managedOrganization: {
|
||||
slug: managedOrganizationSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByManagerManagedIds(managerOrganizationId: number, managedOrganizationId: number) {
|
||||
return this.dbRead.prisma.managedOrganization.findUnique({
|
||||
where: {
|
||||
@@ -35,11 +47,26 @@ export class ManagedOrganizationsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async getByManagerOrganizationIdPaginated(managerOrganizationId: number, pagination: SkipTakePagination) {
|
||||
const { skip, take } = pagination;
|
||||
async getByManagerOrganizationIdPaginated(
|
||||
managerOrganizationId: number,
|
||||
query: GetManagedOrganizationsInput_2024_08_13
|
||||
) {
|
||||
const { skip, take, slug, metadataKey, metadataValue } = query;
|
||||
|
||||
const managedOrganizationFilter: Prisma.TeamWhereInput = {
|
||||
slug,
|
||||
};
|
||||
|
||||
if (metadataKey && metadataValue) {
|
||||
managedOrganizationFilter.metadata = {
|
||||
path: [metadataKey],
|
||||
equals: metadataValue,
|
||||
};
|
||||
}
|
||||
|
||||
const where: Prisma.ManagedOrganizationWhereInput = {
|
||||
managerOrganizationId,
|
||||
managedOrganization: managedOrganizationFilter,
|
||||
};
|
||||
|
||||
const [totalItems, linkRows] = await this.dbRead.prisma.$transaction([
|
||||
|
||||
+143
-13
@@ -66,6 +66,7 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
|
||||
let managerOrg: Team;
|
||||
let managedOrg: ManagedOrganizationWithApiKeyOutput;
|
||||
let managedOrg2: ManagedOrganizationWithApiKeyOutput;
|
||||
const managerOrgAdminEmail = `organizations-organizations-admin-${randomString()}@api.com`;
|
||||
let managerOrgAdmin: User;
|
||||
let managerOrgAdminApiKey: string;
|
||||
@@ -159,27 +160,36 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
const suffix = randomString(5);
|
||||
const metadataKey = "first-org-metadata-key";
|
||||
const metadataValue = "first-org-metadata-value";
|
||||
const createManagedOrganizationBody: CreateOrganizationInput = {
|
||||
name: `org ${suffix}`,
|
||||
slug: `org-${suffix}`,
|
||||
metadata: { [metadataKey]: metadataValue },
|
||||
};
|
||||
|
||||
const suffix2 = randomString(5);
|
||||
const createManagedOrganizationBodySecond: CreateOrganizationInput = {
|
||||
name: `org2 ${suffix2}`,
|
||||
slug: `org2-${suffix2}`,
|
||||
metadata: { key: "value" },
|
||||
};
|
||||
|
||||
it("should create managed organization", async () => {
|
||||
const suffix = randomString();
|
||||
|
||||
const body: CreateOrganizationInput = {
|
||||
name: `organizations organizations org ${suffix}`,
|
||||
metadata: { key: "value" },
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${managerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send(body)
|
||||
.send(createManagedOrganizationBody)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
managedOrg = responseBody.data;
|
||||
expect(managedOrg?.id).toBeDefined();
|
||||
expect(managedOrg?.name).toEqual(body.name);
|
||||
expect(managedOrg?.slug).toEqual(slugify(body.name));
|
||||
expect(managedOrg?.metadata).toEqual(body.metadata);
|
||||
expect(managedOrg?.name).toEqual(createManagedOrganizationBody.name);
|
||||
expect(managedOrg?.slug).toEqual(createManagedOrganizationBody.slug);
|
||||
expect(managedOrg?.metadata).toEqual(createManagedOrganizationBody.metadata);
|
||||
expect(managedOrg?.apiKey).toBeDefined();
|
||||
|
||||
// note(Lauris): check that managed organization is correctly setup in database
|
||||
@@ -284,6 +294,36 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not create managed organization if slug already exists", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${managerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send(createManagedOrganizationBody)
|
||||
.expect(409);
|
||||
|
||||
expect(response.body.error.message).toBe(
|
||||
`Organization with slug '${createManagedOrganizationBody.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
|
||||
);
|
||||
});
|
||||
|
||||
it("should create second managed organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${managerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.send(createManagedOrganizationBodySecond)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
managedOrg2 = responseBody.data;
|
||||
expect(managedOrg2?.id).toBeDefined();
|
||||
expect(managedOrg2?.name).toEqual(createManagedOrganizationBodySecond.name);
|
||||
expect(managedOrg2?.slug).toEqual(createManagedOrganizationBodySecond.slug);
|
||||
expect(managedOrg2?.metadata).toEqual(createManagedOrganizationBodySecond.metadata);
|
||||
expect(managedOrg2?.apiKey).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should get managed organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
|
||||
@@ -304,12 +344,69 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
.get(`/v2/organizations/${managerOrg.id}/organizations`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetManagedOrganizationsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrgs = responseBody.data;
|
||||
expect(responseManagedOrgs?.length).toEqual(2);
|
||||
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
|
||||
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
|
||||
|
||||
const responseManagedOrg2 = responseManagedOrgs.find((org) => org.id === managedOrg2.id);
|
||||
expect(responseManagedOrg2?.id).toBeDefined();
|
||||
expect(responseManagedOrg2?.name).toEqual(managedOrg2.name);
|
||||
expect(responseManagedOrg2?.metadata).toEqual(managedOrg2.metadata);
|
||||
|
||||
expect(responseBody.pagination).toBeDefined();
|
||||
expect(responseBody.pagination.totalItems).toEqual(2);
|
||||
expect(responseBody.pagination.remainingItems).toEqual(0);
|
||||
expect(responseBody.pagination.returnedItems).toEqual(2);
|
||||
expect(responseBody.pagination.itemsPerPage).toEqual(250);
|
||||
expect(responseBody.pagination.currentPage).toEqual(1);
|
||||
expect(responseBody.pagination.totalPages).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get managed organization by slug", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${managerOrg.id}/organizations?slug=${managedOrg.slug}`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetManagedOrganizationsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrgs = responseBody.data;
|
||||
expect(responseManagedOrgs?.length).toEqual(1);
|
||||
const responseManagedOrg = responseManagedOrgs[0];
|
||||
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
|
||||
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
|
||||
|
||||
expect(responseBody.pagination).toBeDefined();
|
||||
expect(responseBody.pagination.totalItems).toEqual(1);
|
||||
expect(responseBody.pagination.remainingItems).toEqual(0);
|
||||
expect(responseBody.pagination.returnedItems).toEqual(1);
|
||||
expect(responseBody.pagination.itemsPerPage).toEqual(250);
|
||||
expect(responseBody.pagination.currentPage).toEqual(1);
|
||||
expect(responseBody.pagination.totalPages).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get managed organization by metadata key", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${managerOrg.id}/organizations?metadataKey=${metadataKey}&metadataValue=${metadataValue}`
|
||||
)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetManagedOrganizationsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrgs = responseBody.data;
|
||||
expect(responseManagedOrgs?.length).toEqual(1);
|
||||
const responseManagedOrg = responseManagedOrgs.find((org) => org.id === managedOrg.id);
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.name).toEqual(managedOrg.name);
|
||||
expect(responseManagedOrg?.metadata).toEqual(managedOrg.metadata);
|
||||
@@ -465,7 +562,40 @@ describe("Organizations Organizations Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete managed organization ", async () => {
|
||||
it("should delete managed organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg2.id}`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<ManagedOrganizationWithApiKeyOutput> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const responseManagedOrg = responseBody.data;
|
||||
expect(responseManagedOrg?.id).toBeDefined();
|
||||
expect(responseManagedOrg?.id).toEqual(managedOrg2.id);
|
||||
expect(responseManagedOrg?.name).toEqual(managedOrg2.name);
|
||||
|
||||
const managedOrgInDb =
|
||||
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managedOrg2.id);
|
||||
expect(managedOrgInDb).toEqual(null);
|
||||
|
||||
const billings = await platformBillingRepositoryFixture.getByCustomerSubscriptionIds(
|
||||
managerOrgBilling.customerId,
|
||||
managerOrgBilling.subscriptionId!
|
||||
);
|
||||
expect(billings).toBeDefined();
|
||||
// note(Lauris): manager billing is left and other managed org
|
||||
expect(billings?.length).toEqual(2);
|
||||
|
||||
const managerOrgInDb =
|
||||
await managedOrganizationsRepositoryFixture.getOrganizationWithManagedOrganizations(managerOrg.id);
|
||||
expect(managerOrgInDb).toBeDefined();
|
||||
expect(managerOrgInDb?.id).toEqual(managerOrg.id);
|
||||
expect(managerOrgInDb?.managedOrganizations?.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete managed organization", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/organizations/${managerOrg.id}/organizations/${managedOrg.id}`)
|
||||
.set("Authorization", `Bearer ${managerOrgAdminApiKey}`)
|
||||
|
||||
+3
-2
@@ -11,6 +11,7 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
|
||||
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
|
||||
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
|
||||
import { CreateManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/create-managed-organization.output";
|
||||
import { GetManagedOrganizationOutput } from "@/modules/organizations/organizations/outputs/get-managed-organization.output";
|
||||
@@ -102,10 +103,10 @@ export class OrganizationsOrganizationsController {
|
||||
})
|
||||
async getOrganizations(
|
||||
@Param("orgId", ParseIntPipe) managerOrganizationId: number,
|
||||
@Query() queryPagination: SkipTakePagination
|
||||
@Query() query: GetManagedOrganizationsInput_2024_08_13
|
||||
): Promise<GetManagedOrganizationsOutput> {
|
||||
const { organizations, pagination: responsePagination } =
|
||||
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, queryPagination);
|
||||
await this.managedOrganizationsService.getManagedOrganizations(managerOrganizationId, query);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: organizations,
|
||||
|
||||
+20
-4
@@ -5,11 +5,12 @@ import { ManagedOrganizationsBillingService } from "@/modules/billing/services/m
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { CreateOrganizationInput } from "@/modules/organizations/organizations/inputs/create-managed-organization.input";
|
||||
import { GetManagedOrganizationsInput_2024_08_13 } from "@/modules/organizations/organizations/inputs/get-managed-organizations.input";
|
||||
import { UpdateOrganizationInput } from "@/modules/organizations/organizations/inputs/update-managed-organization.input";
|
||||
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
|
||||
import { ManagedOrganizationsOutputService } from "@/modules/organizations/organizations/services/managed-organizations-output.service";
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ConflictException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { slugify } from "@calcom/platform-libraries";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@@ -44,6 +45,18 @@ export class ManagedOrganizationsService {
|
||||
organizationData.slug = slugify(organizationData.name);
|
||||
}
|
||||
|
||||
const existingManagedOrganization =
|
||||
await this.managedOrganizationsRepository.getManagedOrganizationBySlug(
|
||||
managerOrganizationId,
|
||||
organizationData.slug
|
||||
);
|
||||
|
||||
if (existingManagedOrganization) {
|
||||
throw new ConflictException(
|
||||
`Organization with slug '${organizationData.slug}' already exists. Please, either provide a different slug or change name so that the automatically generated slug is different.`
|
||||
);
|
||||
}
|
||||
|
||||
const organization = await this.managedOrganizationsRepository.createManagedOrganization(
|
||||
managerOrganizationId,
|
||||
{
|
||||
@@ -101,11 +114,14 @@ export class ManagedOrganizationsService {
|
||||
return this.managedOrganizationsOutputService.getOutputManagedOrganization(organization);
|
||||
}
|
||||
|
||||
async getManagedOrganizations(managerOrganizationId: number, pagination: SkipTakePagination) {
|
||||
async getManagedOrganizations(
|
||||
managerOrganizationId: number,
|
||||
query: GetManagedOrganizationsInput_2024_08_13
|
||||
) {
|
||||
const { items: managedOrganizations, totalItems } =
|
||||
await this.managedOrganizationsRepository.getByManagerOrganizationIdPaginated(
|
||||
managerOrganizationId,
|
||||
pagination
|
||||
query
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -113,7 +129,7 @@ export class ManagedOrganizationsService {
|
||||
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
|
||||
),
|
||||
pagination: getPagination({
|
||||
...pagination,
|
||||
...query,
|
||||
totalCount: totalItems,
|
||||
}),
|
||||
};
|
||||
|
||||
+1
-1
@@ -93,7 +93,7 @@ export class OrganizationTeamWorkflowsController {
|
||||
|
||||
@Patch("/:workflowId")
|
||||
@UseGuards(IsWorkflowInTeam)
|
||||
@ApiOperation({ summary: "Update workflow" })
|
||||
@ApiOperation({ summary: "Update organization team workflow" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
async updateWorkflow(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsBoolean,
|
||||
@@ -74,6 +74,24 @@ export type TriggerDtoType =
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiExtraModels(
|
||||
OnBeforeEventTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
WorkflowEmailAddressStepDto,
|
||||
WorkflowEmailAttendeeStepDto,
|
||||
WorkflowEmailHostStepDto,
|
||||
WorkflowPhoneWhatsAppAttendeeStepDto,
|
||||
WorkflowPhoneWhatsAppNumberStepDto,
|
||||
WorkflowPhoneNumberStepDto,
|
||||
WorkflowPhoneAttendeeStepDto,
|
||||
BaseWorkflowTriggerDto
|
||||
)
|
||||
export class CreateWorkflowDto {
|
||||
@ApiProperty({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
@@ -86,17 +104,15 @@ export class CreateWorkflowDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: "Trigger configuration for the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCancelTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCreationTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
],
|
||||
},
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCancelTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCreationTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
],
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
@@ -124,17 +140,16 @@ export class CreateWorkflowDto {
|
||||
|
||||
@ApiProperty({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailHostStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneAttendeeStepDto) },
|
||||
],
|
||||
},
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(WorkflowEmailAddressStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowEmailHostStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppAttendeeStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneWhatsAppNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneNumberStepDto) },
|
||||
{ $ref: getSchemaPath(WorkflowPhoneAttendeeStepDto) },
|
||||
],
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { ApiExtraModels, ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsNumber, IsString, IsOptional, ValidateNested, ArrayMinSize } from "class-validator";
|
||||
|
||||
@@ -113,6 +113,23 @@ export class UpdateWhatsAppAttendeePhoneWorkflowStepDto extends WorkflowPhoneWha
|
||||
id?: number;
|
||||
}
|
||||
|
||||
@ApiExtraModels(
|
||||
OnBeforeEventTriggerDto,
|
||||
OnAfterEventTriggerDto,
|
||||
OnCancelTriggerDto,
|
||||
OnCreationTriggerDto,
|
||||
OnRescheduleTriggerDto,
|
||||
OnAfterCalVideoGuestsNoShowTriggerDto,
|
||||
OnAfterCalVideoHostsNoShowTriggerDto,
|
||||
UpdateEmailAddressWorkflowStepDto,
|
||||
UpdateEmailAttendeeWorkflowStepDto,
|
||||
UpdateEmailHostWorkflowStepDto,
|
||||
UpdatePhoneAttendeeWorkflowStepDto,
|
||||
UpdatePhoneWhatsAppNumberWorkflowStepDto,
|
||||
UpdateWhatsAppAttendeePhoneWorkflowStepDto,
|
||||
UpdatePhoneNumberWorkflowStepDto,
|
||||
BaseWorkflowTriggerDto
|
||||
)
|
||||
export class UpdateWorkflowDto {
|
||||
@ApiPropertyOptional({ description: "Name of the workflow", example: "Platform Test Workflow" })
|
||||
@IsString()
|
||||
@@ -128,6 +145,20 @@ export class UpdateWorkflowDto {
|
||||
@IsOptional()
|
||||
activation?: WorkflowActivationDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Trigger configuration for the workflow",
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(OnBeforeEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterEventTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCancelTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnCreationTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnRescheduleTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoGuestsNoShowTriggerDto) },
|
||||
{ $ref: getSchemaPath(OnAfterCalVideoHostsNoShowTriggerDto) },
|
||||
],
|
||||
})
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => BaseWorkflowTriggerDto, {
|
||||
discriminator: {
|
||||
property: "type",
|
||||
@@ -142,7 +173,7 @@ export class UpdateWorkflowDto {
|
||||
],
|
||||
},
|
||||
})
|
||||
trigger!:
|
||||
trigger?:
|
||||
| OnAfterEventTriggerDto
|
||||
| OnBeforeEventTriggerDto
|
||||
| OnCreationTriggerDto
|
||||
@@ -150,19 +181,19 @@ export class UpdateWorkflowDto {
|
||||
| OnCancelTriggerDto
|
||||
| OnAfterCalVideoGuestsNoShowTriggerDto
|
||||
| OnAfterCalVideoHostsNoShowTriggerDto;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "Steps to execute as part of the workflow",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailHostWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneWhatsAppNumberWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateWhatsAppAttendeePhoneWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneNumberWorkflowStepDto) },
|
||||
],
|
||||
},
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(UpdateEmailAddressWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateEmailHostWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneAttendeeWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneWhatsAppNumberWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdateWhatsAppAttendeePhoneWorkflowStepDto) },
|
||||
{ $ref: getSchemaPath(UpdatePhoneNumberWorkflowStepDto) },
|
||||
],
|
||||
type: "array",
|
||||
})
|
||||
@ValidateNested({ each: true })
|
||||
@ArrayMinSize(1, { message: "Your workflow must contain at least one step." })
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,4 +35,5 @@ process.env = {
|
||||
VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",
|
||||
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
|
||||
INTEGRATION_TEST_MODE: "true",
|
||||
e2e: "true",
|
||||
};
|
||||
|
||||
@@ -3,11 +3,15 @@ import type { PageProps, ReadonlyHeaders, ReadonlyRequestCookies } from "app/_ty
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { AvailabilityList, AvailabilityCTA } from "~/availability/availability-view";
|
||||
|
||||
@@ -23,15 +27,6 @@ export const generateMetadata = async () => {
|
||||
);
|
||||
};
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // Cache for 1 hour
|
||||
);
|
||||
|
||||
const getCachedAvailabilities = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const availabilityCaller = await createRouterCaller(
|
||||
@@ -49,11 +44,12 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
const t = await getTranslate();
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const [me, cachedAvailabilities] = await Promise.all([
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedAvailabilities(_headers, _cookies),
|
||||
]);
|
||||
const cachedAvailabilities = await getCachedAvailabilities(_headers, _cookies);
|
||||
|
||||
// Transform the data to ensure startTime, endTime, and date are Date objects
|
||||
// This is because the data is cached and as a result the data is converted to a string
|
||||
@@ -70,13 +66,14 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
})),
|
||||
};
|
||||
|
||||
const organizationId = me.organization?.id ?? me.profiles[0]?.organizationId;
|
||||
const organizationId = session?.user?.profile?.organizationId ?? session?.user.org?.id;
|
||||
const isOrgPrivate = organizationId
|
||||
? await OrganizationRepository.checkIfPrivate({
|
||||
orgId: organizationId,
|
||||
})
|
||||
: false;
|
||||
const canViewTeamAvailability = me.organization?.isOrgAdmin || !isOrgPrivate;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgPrivate;
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
@@ -91,9 +88,9 @@ const Page = async ({ searchParams: _searchParams }: PageProps) => {
|
||||
/>
|
||||
}>
|
||||
{searchParams?.type === "team" && canViewTeamAvailability ? (
|
||||
<AvailabilitySliderTable userTimeFormat={me?.timeFormat ?? null} isOrg={!!organizationId} />
|
||||
<AvailabilitySliderTable isOrg={!!organizationId} />
|
||||
) : (
|
||||
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} me={me} />
|
||||
<AvailabilityList availabilities={availabilities ?? { schedules: [] }} />
|
||||
)}
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
|
||||
@@ -9,9 +9,8 @@ import { redirect } from "next/navigation";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
|
||||
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import EventTypes, { EventTypesCTA } from "~/event-types/views/event-types-listing-view";
|
||||
|
||||
@@ -24,15 +23,6 @@ export const generateMetadata = async () =>
|
||||
"/event-types"
|
||||
);
|
||||
|
||||
const getCachedMe = unstable_cache(
|
||||
async (headers: ReadonlyHeaders, cookies: ReadonlyRequestCookies) => {
|
||||
const meCaller = await createRouterCaller(meRouter, await getTRPCContext(headers, cookies));
|
||||
return await meCaller.get();
|
||||
},
|
||||
["viewer.me.get"],
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const getCachedEventGroups = unstable_cache(
|
||||
async (
|
||||
headers: ReadonlyHeaders,
|
||||
@@ -53,30 +43,26 @@ const getCachedEventGroups = unstable_cache(
|
||||
{ revalidate: 3600 } // seconds
|
||||
);
|
||||
|
||||
const Page = async ({ params, searchParams }: PageProps) => {
|
||||
const Page = async ({ searchParams }: PageProps) => {
|
||||
const _searchParams = await searchParams;
|
||||
const _headers = await headers();
|
||||
const _cookies = await cookies();
|
||||
const context = buildLegacyCtx(_headers, _cookies, await params, _searchParams);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
|
||||
if (!session?.user?.id) {
|
||||
redirect("/auth/login");
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const t = await getTranslate();
|
||||
const filters = getTeamsFiltersFromQuery(_searchParams);
|
||||
const [me, userEventGroupsData] = await Promise.all([
|
||||
getCachedMe(_headers, _cookies),
|
||||
getCachedEventGroups(_headers, _cookies, filters),
|
||||
]);
|
||||
const userEventGroupsData = await getCachedEventGroups(_headers, _cookies, filters);
|
||||
|
||||
return (
|
||||
<ShellMainAppDir
|
||||
heading={t("event_types_page_title")}
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
CTA={<EventTypesCTA userEventGroupsData={userEventGroupsData} />}>
|
||||
<EventTypes userEventGroupsData={userEventGroupsData} user={me} />
|
||||
<EventTypes userEventGroupsData={userEventGroupsData} user={session.user} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
+2
-7
@@ -92,7 +92,8 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
? [
|
||||
{
|
||||
name: "members",
|
||||
href: `/settings/organizations/${orgBranding.slug}/members`,
|
||||
href: "https://app.cal.com/settings/organizations/i/members",
|
||||
isExternalLink: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -400,12 +401,6 @@ const TeamListCollapsible = () => {
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
<VerticalTabItem
|
||||
name={t("event_types_page_title")}
|
||||
href={`/event-types?teamId=${team.id}`}
|
||||
textClassNames="px-3 text-emphasis font-medium text-sm"
|
||||
disableChevron
|
||||
/>
|
||||
{(checkAdminOrOwner(team.role) ||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore this exists wtf?
|
||||
|
||||
@@ -15,19 +15,20 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
type AvailabilityListProps = {
|
||||
me: RouterOutputs["viewer"]["me"]["get"];
|
||||
availabilities: RouterOutputs["viewer"]["availability"]["list"];
|
||||
};
|
||||
export function AvailabilityList({ availabilities, me }: AvailabilityListProps) {
|
||||
export function AvailabilityList({ availabilities }: AvailabilityListProps) {
|
||||
const { t } = useLocale();
|
||||
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
const router = useRouter();
|
||||
const { data: user } = useMeQuery();
|
||||
|
||||
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
|
||||
onMutate: async ({ scheduleId }) => {
|
||||
@@ -141,9 +142,9 @@ export function AvailabilityList({ availabilities, me }: AvailabilityListProps)
|
||||
{availabilities.schedules.map((schedule) => (
|
||||
<ScheduleListItem
|
||||
displayOptions={{
|
||||
hour12: me?.timeFormat ? me.timeFormat === 12 : undefined,
|
||||
timeZone: me?.timeZone,
|
||||
weekStart: me?.weekStart || "Sunday",
|
||||
hour12: user?.timeFormat ? user.timeFormat === 12 : undefined,
|
||||
timeZone: user?.timeZone,
|
||||
weekStart: user?.weekStart || "Sunday",
|
||||
}}
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
|
||||
@@ -558,6 +558,14 @@ export default function Success(props: PageProps) {
|
||||
<div className="col-span-2 mb-6 last:mb-0">{cancellationReason}</div>
|
||||
</>
|
||||
)}
|
||||
{isCancelled && bookingInfo?.cancelledBy && (
|
||||
<>
|
||||
<div className="font-medium">{t("cancelled_by")}</div>
|
||||
<div className="col-span-2 mb-6 last:mb-0">
|
||||
<p className="break-words">{bookingInfo?.cancelledBy}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{previousBooking && (
|
||||
<>
|
||||
<div className="font-medium">{t("rescheduled_by")}</div>
|
||||
@@ -613,7 +621,7 @@ export default function Success(props: PageProps) {
|
||||
<Badge variant="blue">{t("Host")}</Badge>
|
||||
</div>
|
||||
{!bookingInfo.eventType?.hideOrganizerEmail && (
|
||||
<p className="text-default">
|
||||
<p className="text-default" data-testid="booking-host-email">
|
||||
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -928,7 +928,10 @@ const InfiniteScrollMain = ({
|
||||
|
||||
type Props = {
|
||||
userEventGroupsData: GetUserEventGroupsResponse;
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
user: {
|
||||
id: number;
|
||||
completedOnboarding?: boolean;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export const EventTypesCTA = ({ userEventGroupsData }: Omit<Props, "user">) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.19",
|
||||
"version": "5.5.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -17,6 +17,7 @@ import { MembershipRole, SchedulingType, TimeUnit, WorkflowTriggerEvents } from
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { Schedule } from "@calcom/types/schedule";
|
||||
|
||||
import { createRoutingForm } from "../lib/test-helpers/routingFormHelpers";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import type { createEmailsFixture } from "./emails";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
@@ -274,6 +275,7 @@ export const createUsersFixture = (
|
||||
| null,
|
||||
scenario: {
|
||||
seedRoutingForms?: boolean;
|
||||
seedRoutingFormWithAttributeRouting?: boolean;
|
||||
hasTeam?: true;
|
||||
numberOfTeams?: number;
|
||||
teamRole?: MembershipRole;
|
||||
@@ -341,192 +343,6 @@ export const createUsersFixture = (
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
|
||||
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
|
||||
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
|
||||
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
routes: [
|
||||
{
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
|
||||
queryValue: {
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
type: "group",
|
||||
children1: {
|
||||
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["event-routing"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
action: { type: "customPageMessage", value: "Custom Page Result" },
|
||||
queryValue: {
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["custom-page"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
children1: {
|
||||
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["external-redirect"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectLegacyFieldUuid,
|
||||
value: [["Option-2"]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectFieldUuid,
|
||||
value: [[multiSelectOption2Uuid]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
type: "text",
|
||||
label: "Test field",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: multiSelectLegacyFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select(with Legacy `selectText`)",
|
||||
identifier: "multi",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: multiSelectFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi-new-format",
|
||||
options: [
|
||||
{
|
||||
id: multiSelectOption1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: multiSelectOption2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: legacySelectFieldUuid,
|
||||
type: "select",
|
||||
label: "Legacy Select",
|
||||
identifier: "test-select",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: selectFieldUuid,
|
||||
type: "select",
|
||||
label: "Select",
|
||||
identifier: "test-select-new-format",
|
||||
options: [
|
||||
{
|
||||
id: selectOption1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: selectOption2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
id: _user.id,
|
||||
},
|
||||
},
|
||||
name: seededForm.name,
|
||||
},
|
||||
});
|
||||
}
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: _user.id },
|
||||
include: userIncludes,
|
||||
@@ -664,7 +480,94 @@ export const createUsersFixture = (
|
||||
}
|
||||
}
|
||||
}
|
||||
const userFixture = createUserFixture(user, store.page);
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
const firstTeamMembership = await prisma.membership.findFirstOrThrow({
|
||||
where: {
|
||||
userId: _user.id,
|
||||
team: {
|
||||
isOrganization: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!firstTeamMembership) {
|
||||
throw new Error("No sub-team created");
|
||||
}
|
||||
await createRoutingForm({
|
||||
userId: _user.id,
|
||||
teamId: firstTeamMembership.teamId,
|
||||
formType: scenario.seedRoutingFormWithAttributeRouting ? "attributeRouting" : "default",
|
||||
...(scenario.seedRoutingFormWithAttributeRouting && {
|
||||
attributeRouting: {
|
||||
attributes: [
|
||||
{
|
||||
name: "Department",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
|
||||
},
|
||||
{
|
||||
name: "Location",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
|
||||
},
|
||||
{
|
||||
name: "Skills",
|
||||
type: "MULTI_SELECT" as const,
|
||||
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
|
||||
},
|
||||
{
|
||||
name: "Years of Experience",
|
||||
type: "NUMBER" as const,
|
||||
},
|
||||
{
|
||||
name: "Bio",
|
||||
type: "TEXT" as const,
|
||||
},
|
||||
],
|
||||
assignments: [
|
||||
{
|
||||
memberIndex: 0,
|
||||
attributeValues: {
|
||||
Location: ["New York"],
|
||||
Skills: ["JavaScript"],
|
||||
},
|
||||
},
|
||||
{
|
||||
memberIndex: 1,
|
||||
attributeValues: {
|
||||
Location: ["London"],
|
||||
Skills: ["React", "JavaScript"],
|
||||
},
|
||||
},
|
||||
],
|
||||
teamEvents: [
|
||||
{
|
||||
title: "Team Sales",
|
||||
slug: "team-sales",
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
assignAllTeamMembers: true,
|
||||
length: 60,
|
||||
description: "Team Sales",
|
||||
},
|
||||
{
|
||||
title: "Team Javascript",
|
||||
slug: "team-javascript",
|
||||
schedulingType: "ROUND_ROBIN",
|
||||
assignAllTeamMembers: true,
|
||||
length: 60,
|
||||
description: "Team Javascript",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const finalUser = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
include: userIncludes,
|
||||
});
|
||||
const userFixture = createUserFixture(finalUser, store.page);
|
||||
store.users.push(userFixture);
|
||||
return userFixture;
|
||||
},
|
||||
@@ -781,7 +684,7 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
getFirstTeamMembership: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
include: { team: true, user: true },
|
||||
});
|
||||
|
||||
const membership = memberships
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { expect, describe, it, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
import { createAttributes } from "../organizationHelpers";
|
||||
|
||||
// Mock console.log to avoid noise in tests
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
|
||||
// Intentionally empty to suppress console output in tests
|
||||
});
|
||||
|
||||
// Default mock attributes matching the original hardcoded values
|
||||
const mockAttributes = [
|
||||
{
|
||||
name: "Department",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
|
||||
},
|
||||
{
|
||||
name: "Location",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
|
||||
},
|
||||
{
|
||||
name: "Skills",
|
||||
type: "MULTI_SELECT" as const,
|
||||
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
|
||||
},
|
||||
{
|
||||
name: "Years of Experience",
|
||||
type: "NUMBER" as const,
|
||||
},
|
||||
{
|
||||
name: "Bio",
|
||||
type: "TEXT" as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Default assignments matching the original hardcoded behavior
|
||||
const mockAssignments = [
|
||||
{
|
||||
memberIndex: 0,
|
||||
attributeValues: {
|
||||
Location: ["New York"],
|
||||
Skills: ["JavaScript"],
|
||||
},
|
||||
},
|
||||
{
|
||||
memberIndex: 1,
|
||||
attributeValues: {
|
||||
Location: ["London"],
|
||||
Skills: ["React", "JavaScript"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("createAttributes", () => {
|
||||
beforeEach(async () => {
|
||||
// Reset prismock state between tests
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await (prismock as any).reset();
|
||||
consoleSpy.mockClear();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("should create all 5 mock attributes for an organization", async () => {
|
||||
const orgId = 1;
|
||||
|
||||
// Create a membership for the organization
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
teamId: orgId,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
// Verify all attributes were created
|
||||
const attributes = await prismock.attribute.findMany({
|
||||
where: { teamId: orgId },
|
||||
include: { options: true },
|
||||
});
|
||||
|
||||
expect(attributes).toHaveLength(5);
|
||||
expect(result).toHaveLength(5);
|
||||
|
||||
// Check specific attributes
|
||||
const departmentAttr = attributes.find((attr) => attr.name === "Department");
|
||||
const locationAttr = attributes.find((attr) => attr.name === "Location");
|
||||
const skillsAttr = attributes.find((attr) => attr.name === "Skills");
|
||||
const experienceAttr = attributes.find((attr) => attr.name === "Years of Experience");
|
||||
const bioAttr = attributes.find((attr) => attr.name === "Bio");
|
||||
|
||||
expect(departmentAttr).toBeDefined();
|
||||
expect(departmentAttr?.type).toBe("SINGLE_SELECT");
|
||||
expect(departmentAttr?.options).toHaveLength(5);
|
||||
|
||||
expect(locationAttr).toBeDefined();
|
||||
expect(locationAttr?.type).toBe("SINGLE_SELECT");
|
||||
expect(locationAttr?.options).toHaveLength(5);
|
||||
|
||||
expect(skillsAttr).toBeDefined();
|
||||
expect(skillsAttr?.type).toBe("MULTI_SELECT");
|
||||
expect(skillsAttr?.options).toHaveLength(6);
|
||||
|
||||
expect(experienceAttr).toBeDefined();
|
||||
expect(experienceAttr?.type).toBe("NUMBER");
|
||||
expect(experienceAttr?.options).toHaveLength(0); // No options created for NUMBER type
|
||||
|
||||
expect(bioAttr).toBeDefined();
|
||||
expect(bioAttr?.type).toBe("TEXT");
|
||||
expect(bioAttr?.options).toHaveLength(0); // No options created for TEXT type
|
||||
});
|
||||
|
||||
it("should assign attribute values to all organization members", async () => {
|
||||
const orgId = 1;
|
||||
|
||||
// Create multiple memberships
|
||||
await prismock.membership.createMany({
|
||||
data: [
|
||||
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
|
||||
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
|
||||
{ id: 3, userId: 3, teamId: orgId, role: "MEMBER", accepted: true },
|
||||
],
|
||||
});
|
||||
|
||||
await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
// Check that attribute assignments were created
|
||||
const attributeAssignments = await prismock.attributeToUser.findMany();
|
||||
const attributeOptions = await prismock.attributeOption.findMany({
|
||||
include: { assignedUsers: true },
|
||||
});
|
||||
|
||||
// Should have assignments only for specific first two members
|
||||
// Member 1: Location (New York) + Skills (JavaScript) = 2 assignments
|
||||
// Member 2: Location (London) + Skills (React + JavaScript) = 3 assignments
|
||||
// Total = 5 assignments (no random assignments for other members/attributes)
|
||||
expect(attributeAssignments.length).toBe(5);
|
||||
|
||||
// Should have 4 options with assigned users:
|
||||
// Location: New York (assigned to user 1) + London (assigned to user 2) = 2 options
|
||||
// Skills: JavaScript (assigned to user 1) + React & JavaScript (assigned to user 2) = 2 unique options
|
||||
const customOptions = attributeOptions.filter((opt) => opt.assignedUsers.length > 0);
|
||||
expect(customOptions.length).toBe(4);
|
||||
});
|
||||
|
||||
it("should skip seeding if attributes already exist", async () => {
|
||||
const orgId = 1;
|
||||
|
||||
// Create a membership
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
teamId: orgId,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-create one of the attributes
|
||||
await prismock.attribute.create({
|
||||
data: {
|
||||
id: "attr-1",
|
||||
name: "Department",
|
||||
slug: "department",
|
||||
type: "SINGLE_SELECT",
|
||||
teamId: orgId,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
// Should return early and not create more attributes
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
const attributes = await prismock.attribute.findMany({
|
||||
where: { teamId: orgId },
|
||||
});
|
||||
|
||||
// Should still only have the 1 pre-existing attribute
|
||||
expect(attributes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should handle organization with no members gracefully", async () => {
|
||||
const orgId = 1;
|
||||
|
||||
// Don't create any memberships
|
||||
|
||||
const result = await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
// Should still create attributes
|
||||
const attributes = await prismock.attribute.findMany({
|
||||
where: { teamId: orgId },
|
||||
});
|
||||
|
||||
expect(attributes).toHaveLength(5);
|
||||
expect(result).toHaveLength(5);
|
||||
|
||||
// But no attribute assignments should be created
|
||||
const attributeAssignments = await prismock.attributeToUser.findMany();
|
||||
expect(attributeAssignments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should assign specific skills and locations to first two members", async () => {
|
||||
const orgId = 1;
|
||||
|
||||
// Create exactly two memberships to test specific assignments
|
||||
await prismock.membership.createMany({
|
||||
data: [
|
||||
{ id: 1, userId: 1, teamId: orgId, role: "OWNER", accepted: true },
|
||||
{ id: 2, userId: 2, teamId: orgId, role: "MEMBER", accepted: true },
|
||||
],
|
||||
});
|
||||
|
||||
await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
// Get the attributes and their options
|
||||
const skillsAttr = await prismock.attribute.findFirst({
|
||||
where: { teamId: orgId, name: "Skills" },
|
||||
include: { options: true },
|
||||
});
|
||||
|
||||
const locationAttr = await prismock.attribute.findFirst({
|
||||
where: { teamId: orgId, name: "Location" },
|
||||
include: { options: true },
|
||||
});
|
||||
|
||||
expect(skillsAttr).toBeDefined();
|
||||
expect(locationAttr).toBeDefined();
|
||||
|
||||
// Check assignments for first member (should have JavaScript skill and New York location)
|
||||
const firstMemberAssignments = await prismock.attributeToUser.findMany({
|
||||
where: { memberId: 1 },
|
||||
include: { attributeOption: true },
|
||||
});
|
||||
|
||||
const firstMemberSkills = firstMemberAssignments
|
||||
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
|
||||
.map((a) => a.attributeOption.value);
|
||||
|
||||
const firstMemberLocation = firstMemberAssignments
|
||||
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
|
||||
.map((a) => a.attributeOption.value);
|
||||
|
||||
expect(firstMemberSkills).toContain("JavaScript");
|
||||
expect(firstMemberLocation).toContain("New York");
|
||||
|
||||
// Check assignments for second member (should have React and JavaScript skills, London location)
|
||||
const secondMemberAssignments = await prismock.attributeToUser.findMany({
|
||||
where: { memberId: 2 },
|
||||
include: { attributeOption: true },
|
||||
});
|
||||
|
||||
const secondMemberSkills = secondMemberAssignments
|
||||
.filter((a) => a.attributeOption.attributeId === skillsAttr!.id)
|
||||
.map((a) => a.attributeOption.value);
|
||||
|
||||
const secondMemberLocation = secondMemberAssignments
|
||||
.filter((a) => a.attributeOption.attributeId === locationAttr!.id)
|
||||
.map((a) => a.attributeOption.value);
|
||||
|
||||
expect(secondMemberSkills).toContain("JavaScript");
|
||||
expect(secondMemberSkills).toContain("React");
|
||||
expect(secondMemberLocation).toContain("London");
|
||||
});
|
||||
|
||||
it("should generate correct slugs and enable all attributes", async () => {
|
||||
const orgId = 123;
|
||||
|
||||
// Create a membership
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
teamId: orgId,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
await createAttributes({
|
||||
orgId,
|
||||
attributes: mockAttributes,
|
||||
assignments: mockAssignments,
|
||||
});
|
||||
|
||||
const attributes = await prismock.attribute.findMany({
|
||||
where: { teamId: orgId },
|
||||
});
|
||||
|
||||
attributes.forEach((attr) => {
|
||||
// All should be enabled
|
||||
expect(attr.enabled).toBe(true);
|
||||
|
||||
// Should have correct slug format
|
||||
expect(attr.slug).toMatch(/^org:123-/);
|
||||
|
||||
// Specific slug checks
|
||||
if (attr.name === "Years of Experience") {
|
||||
expect(attr.slug).toBe("org:123-years-of-experience");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createRoutingForm } from "../routingFormHelpers";
|
||||
|
||||
// Mock console.log to avoid noise in tests
|
||||
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {
|
||||
// Intentionally empty to suppress console output during tests
|
||||
});
|
||||
|
||||
describe("createRoutingForm", () => {
|
||||
beforeEach(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
await prismock.reset();
|
||||
consoleSpy.mockClear();
|
||||
});
|
||||
|
||||
// Common attribute routing config for tests
|
||||
const defaultAttributeRoutingConfig = {
|
||||
attributes: [
|
||||
{
|
||||
name: "Department",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["Engineering", "Sales", "Marketing", "Product", "Design"],
|
||||
},
|
||||
{
|
||||
name: "Location",
|
||||
type: "SINGLE_SELECT" as const,
|
||||
options: ["New York", "London", "Tokyo", "Berlin", "Remote"],
|
||||
},
|
||||
{
|
||||
name: "Skills",
|
||||
type: "MULTI_SELECT" as const,
|
||||
options: ["JavaScript", "React", "Node.js", "Python", "Design", "Sales"],
|
||||
},
|
||||
{
|
||||
name: "Years of Experience",
|
||||
type: "NUMBER" as const,
|
||||
},
|
||||
{
|
||||
name: "Bio",
|
||||
type: "TEXT" as const,
|
||||
},
|
||||
],
|
||||
assignments: [
|
||||
{
|
||||
memberIndex: 0,
|
||||
attributeValues: {
|
||||
Location: ["New York"],
|
||||
Skills: ["JavaScript"],
|
||||
},
|
||||
},
|
||||
{
|
||||
memberIndex: 1,
|
||||
attributeValues: {
|
||||
Location: ["London"],
|
||||
Skills: ["React", "JavaScript"],
|
||||
},
|
||||
},
|
||||
],
|
||||
teamEvents: [
|
||||
{
|
||||
title: "Team Sales",
|
||||
slug: "team-sales",
|
||||
schedulingType: "ROUND_ROBIN" as const,
|
||||
assignAllTeamMembers: true,
|
||||
length: 60,
|
||||
description: "Team Sales",
|
||||
},
|
||||
{
|
||||
title: "Team Javascript",
|
||||
slug: "team-javascript",
|
||||
schedulingType: "ROUND_ROBIN" as const,
|
||||
assignAllTeamMembers: true,
|
||||
length: 60,
|
||||
description: "Team Javascript",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("Default routing form", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user for default form tests
|
||||
await prismock.user.create({
|
||||
data: {
|
||||
id: 1,
|
||||
username: "test-user",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a basic routing form", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const form = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "default",
|
||||
});
|
||||
|
||||
expect(form).toBeDefined();
|
||||
expect(form.name).toBe("Fixture Routing Form");
|
||||
expect(form.userId).toBe(userId);
|
||||
});
|
||||
|
||||
it("should create form with multiple route types", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const form = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "default",
|
||||
});
|
||||
|
||||
expect(form.routes).toBeDefined();
|
||||
const routes = form.routes as any[];
|
||||
|
||||
// Should have event redirect, custom page, external redirect, and multiselect routes
|
||||
expect(routes.length).toBeGreaterThan(4);
|
||||
|
||||
const eventRedirectRoute = routes.find((r) => r.action?.type === "eventTypeRedirectUrl");
|
||||
const customPageRoute = routes.find((r) => r.action?.type === "customPageMessage");
|
||||
const externalRedirectRoute = routes.find((r) => r.action?.type === "externalRedirectUrl");
|
||||
const fallbackRoute = routes.find((r) => r.isFallback === true);
|
||||
|
||||
expect(eventRedirectRoute).toBeDefined();
|
||||
expect(customPageRoute).toBeDefined();
|
||||
expect(externalRedirectRoute).toBeDefined();
|
||||
expect(fallbackRoute).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create form with various field types", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const form = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "default",
|
||||
});
|
||||
|
||||
const fields = form.fields as any[];
|
||||
expect(fields).toBeDefined();
|
||||
expect(fields.length).toBeGreaterThan(3);
|
||||
|
||||
const textField = fields.find((f) => f.type === "text");
|
||||
const multiselectField = fields.find((f) => f.type === "multiselect");
|
||||
const selectField = fields.find((f) => f.type === "select");
|
||||
|
||||
expect(textField).toBeDefined();
|
||||
expect(multiselectField).toBeDefined();
|
||||
expect(selectField).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Attribute routing form", () => {
|
||||
beforeEach(async () => {
|
||||
// Create a test user
|
||||
await prismock.user.create({
|
||||
data: {
|
||||
id: 1,
|
||||
username: "test-user",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
// Setup organization and team structure for attribute routing
|
||||
await prismock.team.createMany({
|
||||
data: [
|
||||
{ id: 100, name: "Test Org", isOrganization: true, slug: "test-org" },
|
||||
{ id: 1, name: "Test Team", isOrganization: false, slug: "test-team", parentId: 100 },
|
||||
],
|
||||
});
|
||||
|
||||
await prismock.membership.createMany({
|
||||
data: [
|
||||
{ id: 1, userId: 1, teamId: 100, role: "OWNER", accepted: true }, // Org membership
|
||||
{ id: 2, userId: 1, teamId: 1, role: "OWNER", accepted: true }, // Team membership
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should create routing form with attribute routing", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const result = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toBe("Form with Attribute Routing");
|
||||
expect(result!.userId).toBe(userId);
|
||||
expect(result!.teamId).toBe(teamId);
|
||||
});
|
||||
|
||||
it("should create team event types for attribute routing", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
});
|
||||
|
||||
// Check that team event types were created
|
||||
const salesEvent = await prismock.eventType.findFirst({
|
||||
where: { slug: "team-sales", teamId },
|
||||
});
|
||||
|
||||
const javascriptEvent = await prismock.eventType.findFirst({
|
||||
where: { slug: "team-javascript", teamId },
|
||||
});
|
||||
|
||||
expect(salesEvent).toBeDefined();
|
||||
expect(salesEvent?.title).toBe("Team Sales");
|
||||
expect(salesEvent?.schedulingType).toBe("ROUND_ROBIN");
|
||||
|
||||
expect(javascriptEvent).toBeDefined();
|
||||
expect(javascriptEvent?.title).toBe("Team Javascript");
|
||||
expect(javascriptEvent?.schedulingType).toBe("ROUND_ROBIN");
|
||||
});
|
||||
|
||||
it("should create attributes for the organization", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
});
|
||||
|
||||
// Check that attributes were created for the organization
|
||||
const attributes = await prismock.attribute.findMany({
|
||||
where: { teamId: 100 }, // Organization ID
|
||||
include: { options: true },
|
||||
});
|
||||
|
||||
expect(attributes.length).toBeGreaterThan(0);
|
||||
|
||||
const skillsAttribute = attributes.find((attr) => attr.name === "Skills");
|
||||
const locationAttribute = attributes.find((attr) => attr.name === "Location");
|
||||
|
||||
expect(skillsAttribute).toBeDefined();
|
||||
expect(locationAttribute).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create routes with attribute-based routing logic", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const result = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
});
|
||||
|
||||
const routes = result!.routes as any[];
|
||||
expect(routes).toBeDefined();
|
||||
expect(routes.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Should have routes with attributesQueryValue
|
||||
const attributeRoutes = routes.filter((route) => route.attributesQueryValue);
|
||||
expect(attributeRoutes.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Should have fallback route
|
||||
const fallbackRoute = routes.find((route) => route.isFallback === true);
|
||||
expect(fallbackRoute).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create form fields matching attributes", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
const result = await createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
});
|
||||
|
||||
const fields = result!.fields as any[];
|
||||
expect(fields).toBeDefined();
|
||||
expect(fields.length).toBe(5);
|
||||
|
||||
const locationField = fields.find((f) => f.label === "Location");
|
||||
const skillsField = fields.find((f) => f.label === "skills");
|
||||
const emailField = fields.find((f) => f.label === "Email");
|
||||
const nameField = fields.find((f) => f.label === "Name");
|
||||
const ratingField = fields.find((f) => f.label === "Rating");
|
||||
|
||||
expect(locationField?.type).toBe("select");
|
||||
expect(skillsField?.type).toBe("multiselect");
|
||||
expect(emailField?.type).toBe("email");
|
||||
expect(nameField?.type).toBe("text");
|
||||
expect(ratingField?.type).toBe("number");
|
||||
|
||||
// Check required fields
|
||||
expect(locationField?.required).toBe(true);
|
||||
expect(skillsField?.required).toBe(true);
|
||||
expect(emailField?.required).toBe(true);
|
||||
expect(nameField?.required).toBe(false);
|
||||
expect(ratingField?.required).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle missing attributeRouting config", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 1;
|
||||
|
||||
await expect(
|
||||
createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
})
|
||||
).rejects.toThrow("attributeRouting config is required when formType is 'attributeRouting'");
|
||||
});
|
||||
|
||||
it("should handle missing organization membership", async () => {
|
||||
const userId = 2; // User without org membership
|
||||
const teamId = 1;
|
||||
|
||||
// Create a test user without org membership
|
||||
await prismock.user.create({
|
||||
data: {
|
||||
id: 2,
|
||||
username: "test-user-2",
|
||||
email: "test2@example.com",
|
||||
name: "Test User 2",
|
||||
},
|
||||
});
|
||||
|
||||
// Only create team membership, not org membership
|
||||
await prismock.membership.create({
|
||||
data: { id: 3, userId: 2, teamId: 1, role: "MEMBER", accepted: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should handle missing team", async () => {
|
||||
const userId = 1;
|
||||
const teamId = 999; // Non-existent team
|
||||
|
||||
await expect(
|
||||
createRoutingForm({
|
||||
userId,
|
||||
teamId,
|
||||
formType: "attributeRouting",
|
||||
attributeRouting: defaultAttributeRoutingConfig,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
/**
|
||||
* Example usage:
|
||||
*
|
||||
* // Basic usage with default assignments
|
||||
* await createAttributes({
|
||||
* orgId: 1,
|
||||
* attributes: [
|
||||
* {
|
||||
* name: "Department",
|
||||
* type: "SINGLE_SELECT",
|
||||
* options: ["Engineering", "Sales", "Marketing"]
|
||||
* }
|
||||
* ],
|
||||
* assignments: [
|
||||
* {
|
||||
* memberIndex: 0,
|
||||
* attributeValues: {
|
||||
* "Department": ["Engineering"]
|
||||
* }
|
||||
* }
|
||||
* ]
|
||||
* });
|
||||
*
|
||||
* // Create attributes without assignments
|
||||
* await createAttributes({
|
||||
* orgId: 1,
|
||||
* attributes: [
|
||||
* { name: "Bio", type: "TEXT" },
|
||||
* { name: "Years of Experience", type: "NUMBER" }
|
||||
* ]
|
||||
* });
|
||||
*/
|
||||
|
||||
interface AttributeConfig {
|
||||
name: string;
|
||||
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
interface MemberAssignment {
|
||||
memberIndex: number;
|
||||
attributeValues: Record<string, string[]>; // attribute name -> selected values
|
||||
}
|
||||
|
||||
interface SeedAttributesConfig {
|
||||
orgId: number;
|
||||
attributes: AttributeConfig[];
|
||||
assignments?: MemberAssignment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds attributes for an organization with configurable types, values, and assignments
|
||||
* @param config - Configuration object containing orgId, attributes, and optional assignments
|
||||
* @returns Array of created attributes with their options
|
||||
*/
|
||||
export async function createAttributes(config: SeedAttributesConfig) {
|
||||
const { orgId, attributes: attributeConfigs, assignments = [] } = config;
|
||||
|
||||
console.log(`🎯 Seeding attributes for org ${orgId}`);
|
||||
|
||||
// Check if attributes already exist
|
||||
const existingAttributes = await prisma.attribute.findMany({
|
||||
where: {
|
||||
teamId: orgId,
|
||||
name: {
|
||||
in: attributeConfigs.map((attr) => attr.name),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingAttributes.length > 0) {
|
||||
console.log(`Skipping attributes seed, attributes already exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: orgId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`🎯 Creating attributes for org ${orgId}`);
|
||||
|
||||
const attributes: { id: string; name: string; options: { id: string; value: string }[] }[] = [];
|
||||
|
||||
for (const attr of attributeConfigs) {
|
||||
const attribute = await prisma.attribute.create({
|
||||
data: {
|
||||
name: attr.name,
|
||||
slug: `org:${orgId}-${attr.name.toLowerCase().replace(/ /g, "-")}`,
|
||||
type: attr.type as "TEXT" | "NUMBER" | "SINGLE_SELECT" | "MULTI_SELECT",
|
||||
teamId: orgId,
|
||||
enabled: true,
|
||||
options: attr.options
|
||||
? {
|
||||
create: attr.options.map((opt) => ({
|
||||
value: opt,
|
||||
slug: opt.toLowerCase().replace(/ /g, "-"),
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
options: true,
|
||||
},
|
||||
});
|
||||
|
||||
attributes.push({
|
||||
id: attribute.id,
|
||||
name: attribute.name,
|
||||
options: attribute.options.map((opt) => ({
|
||||
id: opt.id,
|
||||
value: opt.value,
|
||||
})),
|
||||
});
|
||||
|
||||
console.log(`\t📝 Created attribute: ${attr.name}`);
|
||||
|
||||
// Process assignments for this attribute
|
||||
for (const assignment of assignments) {
|
||||
const { memberIndex, attributeValues } = assignment;
|
||||
|
||||
// Check if this assignment has values for the current attribute
|
||||
const valuesForAttribute = attributeValues[attr.name];
|
||||
if (!valuesForAttribute || valuesForAttribute.length === 0) {
|
||||
console.log(
|
||||
`\t\t⏭️ Skipped ${attr.name} assignment for member ${memberIndex + 1} - no values specified`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the member at the specified index
|
||||
const member = memberships[memberIndex];
|
||||
if (!member) {
|
||||
console.log(`\t\t⚠️ Skipped ${attr.name} assignment - member at index ${memberIndex} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the options for the specified values
|
||||
const selectedOptions = attribute.options.filter((opt) => valuesForAttribute.includes(opt.value));
|
||||
|
||||
if (selectedOptions.length === 0) {
|
||||
console.log(
|
||||
`\t\t⚠️ Skipped ${attr.name} assignment for user ${
|
||||
member.userId
|
||||
} - no matching options found for values: ${valuesForAttribute.join(", ")}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create assignments for each selected option
|
||||
for (const option of selectedOptions) {
|
||||
await prisma.attributeToUser.create({
|
||||
data: {
|
||||
memberId: member.id,
|
||||
attributeOptionId: option.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const assignedValues = selectedOptions.map((opt) => opt.value);
|
||||
console.log(
|
||||
`\t\t✅ Assigned ${attr.name} [${assignedValues.map((v) => `"${v}"`).join(", ")}] to user ${
|
||||
member.userId
|
||||
} (member ${memberIndex + 1})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { createAttributes } from "./organizationHelpers";
|
||||
import { createRoundRobinTeamEventType } from "./teamHelpers";
|
||||
|
||||
/**
|
||||
* Configuration for form fields
|
||||
*/
|
||||
interface FieldConfig {
|
||||
id?: string;
|
||||
type: "text" | "email" | "select" | "multiselect" | "number";
|
||||
label: string;
|
||||
identifier?: string;
|
||||
required?: boolean;
|
||||
options?: { id: string; label: string }[];
|
||||
selectText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for form routes
|
||||
*/
|
||||
interface RouteConfig {
|
||||
id?: string;
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl" | "customPageMessage" | "externalRedirectUrl";
|
||||
value: string;
|
||||
};
|
||||
isFallback?: boolean;
|
||||
queryValue?: any;
|
||||
attributesQueryValue?: any;
|
||||
fallbackAttributesQueryValue?: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for team events in attribute routing
|
||||
*/
|
||||
interface TeamEventConfig {
|
||||
title: string;
|
||||
slug: string;
|
||||
schedulingType?: "ROUND_ROBIN" | "COLLECTIVE";
|
||||
assignAllTeamMembers?: boolean;
|
||||
length?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating test routing forms
|
||||
*/
|
||||
interface CreateRoutingFormConfig {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
/**
|
||||
* Whether to setup with attribute routing or not
|
||||
*/
|
||||
formType: "default" | "attributeRouting";
|
||||
|
||||
// Basic form settings
|
||||
form?: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
fields?: FieldConfig[];
|
||||
routes?: RouteConfig[];
|
||||
};
|
||||
|
||||
// For attribute routing (required when formType is "attributeRouting")
|
||||
attributeRouting?: {
|
||||
orgId?: number; // If not provided, will find from userId
|
||||
attributes: {
|
||||
name: string;
|
||||
type: "SINGLE_SELECT" | "MULTI_SELECT" | "TEXT" | "NUMBER";
|
||||
options?: string[];
|
||||
}[];
|
||||
assignments: {
|
||||
memberIndex: number;
|
||||
attributeValues: Record<string, string[]>;
|
||||
}[];
|
||||
teamEvents: TeamEventConfig[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a test routing form with configurable options
|
||||
*
|
||||
* The routes are not configurable at the moment
|
||||
* The hardcoded routes are:
|
||||
* - Route-1: Skills:Javascript routes to team/team-javascript selecting only those team members who have JavaScript in their skills
|
||||
* - Route-2: Skills:Sales routes to team/team-sales selecting only those team members who have Sales in their skills
|
||||
* - Route-3: Fallback Message
|
||||
*
|
||||
* The hardcoded attributes are:
|
||||
* - Location
|
||||
* - Skills
|
||||
* @param config - Configuration object containing all routing form settings
|
||||
* @returns The created routing form
|
||||
*/
|
||||
export async function createRoutingForm(config: CreateRoutingFormConfig) {
|
||||
const { userId, teamId, formType } = config;
|
||||
|
||||
if (formType === "attributeRouting") {
|
||||
// Validate required config
|
||||
if (!config.attributeRouting) {
|
||||
throw new Error("attributeRouting config is required when formType is 'attributeRouting'");
|
||||
}
|
||||
|
||||
// Find org membership
|
||||
const orgMembership = await prisma.membership.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
team: {
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!orgMembership) {
|
||||
throw new Error("Organization membership not found");
|
||||
}
|
||||
|
||||
const orgId = config.attributeRouting.orgId || orgMembership.teamId;
|
||||
const team = await prisma.team.findUniqueOrThrow({
|
||||
where: {
|
||||
id: teamId,
|
||||
},
|
||||
});
|
||||
|
||||
// Use provided team events configuration
|
||||
const teamEventsConfig = config.attributeRouting.teamEvents;
|
||||
|
||||
// Create team events
|
||||
const teamEvents: { [key: string]: any } = {};
|
||||
for (const eventConfig of teamEventsConfig) {
|
||||
const eventType = await createRoundRobinTeamEventType({
|
||||
teamId,
|
||||
eventType: { ...eventConfig, length: eventConfig.length || 60 },
|
||||
});
|
||||
teamEvents[eventConfig.slug] = eventType;
|
||||
}
|
||||
|
||||
// Use provided attributes and assignments configuration
|
||||
const attributes = await createAttributes({
|
||||
orgId,
|
||||
attributes: config.attributeRouting.attributes,
|
||||
assignments: config.attributeRouting.assignments,
|
||||
});
|
||||
|
||||
if (!attributes) {
|
||||
throw new Error("Attributes not found");
|
||||
}
|
||||
|
||||
const skillAttribute = attributes.find((attr) => attr.name === "Skills")!;
|
||||
const locationAttribute = attributes.find((attr) => attr.name === "Location")!;
|
||||
|
||||
console.log("Attributes", JSON.stringify(attributes, null, 2));
|
||||
|
||||
const javascriptEventRoute = {
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
value: `team/${team.slug}/team-javascript`,
|
||||
};
|
||||
|
||||
const salesEventRoute = {
|
||||
id: "8b2224b2-89ab-4cde-b012-31823f708642",
|
||||
value: `team/${team.slug}/team-sales`,
|
||||
};
|
||||
|
||||
const form = {
|
||||
name: config.form?.name || "Form with Attribute Routing",
|
||||
routes: [javascriptEventRoute, salesEventRoute],
|
||||
formFieldLocation: {
|
||||
id: "674c169a-e40a-492c-b4bb-6f5213873bd6",
|
||||
},
|
||||
formFieldSkills: {
|
||||
id: "83316968-45bf-4c9d-b5d4-5368a8d2d2a8",
|
||||
},
|
||||
formFieldEmail: {
|
||||
id: "dd28ffcf-7029-401e-bddb-ce2e7496a1c1",
|
||||
},
|
||||
formFieldName: {
|
||||
id: "57734f65-8bbb-4065-9e71-fb7f0b7485f8",
|
||||
},
|
||||
formFieldRating: {
|
||||
id: "f4e9fa6c-5c5d-4d8e-b15c-7f37e9d0c729",
|
||||
},
|
||||
};
|
||||
|
||||
const attributeRaw = {
|
||||
location: {
|
||||
id: "location-id",
|
||||
options: [
|
||||
{ id: "Location-London-uuid", value: "London" },
|
||||
{ id: "Location-New-York-uuid", value: "New York" },
|
||||
],
|
||||
},
|
||||
skills: {
|
||||
id: "skills-id",
|
||||
options: [
|
||||
{ id: "Skills-JavaScript-uuid", value: "JavaScript" },
|
||||
{ id: "Skills-Sales-uuid", value: "Sales" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const formFieldSkillsOptions = attributeRaw.skills.options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
}));
|
||||
|
||||
const formFieldLocationOptions = attributeRaw.location.options.map((opt) => ({
|
||||
id: opt.id,
|
||||
label: opt.value,
|
||||
}));
|
||||
|
||||
const createdForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
id: uuid(),
|
||||
routes: [
|
||||
{
|
||||
id: javascriptEventRoute.id,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: javascriptEventRoute.value,
|
||||
},
|
||||
queryValue: {
|
||||
id: "aaba9988-cdef-4012-b456-719300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98b98a8-0123-4456-b89a-b19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: form.formFieldSkills.id,
|
||||
value: [
|
||||
formFieldSkillsOptions.filter((opt) => opt.label === "JavaScript").map((opt) => opt.id),
|
||||
],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: {
|
||||
id: "ab99bbb9-89ab-4cde-b012-319300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98b98a8-0123-4456-b89a-b19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: skillAttribute.id,
|
||||
value: [
|
||||
skillAttribute.options.filter((opt) => opt.value === "JavaScript").map((opt) => opt.id),
|
||||
],
|
||||
operator: "multiselect_some_in",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: salesEventRoute.id,
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
value: salesEventRoute.value,
|
||||
},
|
||||
queryValue: {
|
||||
id: "aaba9948-cdef-4012-b456-719300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"c98b98a8-1123-4456-e89a-a19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: form.formFieldSkills.id,
|
||||
value: [
|
||||
formFieldSkillsOptions.filter((opt) => opt.label === "Sales").map((opt) => opt.id),
|
||||
],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
attributesQueryValue: {
|
||||
id: "ab988888-89ab-4cde-b012-319300f53ef8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98b98a12-0123-4456-b89a-b19300f55277": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: skillAttribute.id,
|
||||
value: [
|
||||
skillAttribute.options.filter((opt) => opt.value === "Sales").map((opt) => opt.id),
|
||||
],
|
||||
operator: "multiselect_some_in",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
valueError: [null],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
fallbackAttributesQueryValue: {
|
||||
id: "a9888488-4567-489a-bcde-f19300f53ef8",
|
||||
type: "group",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "148899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "814899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: config.form?.fields || [
|
||||
{
|
||||
id: form.formFieldLocation.id,
|
||||
type: "select",
|
||||
label: "Location",
|
||||
options: formFieldLocationOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: form.formFieldSkills.id,
|
||||
type: "multiselect",
|
||||
label: "skills",
|
||||
options: formFieldSkillsOptions,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: form.formFieldEmail.id,
|
||||
type: "email",
|
||||
label: "Email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: form.formFieldName.id,
|
||||
identifier: "name",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: form.formFieldRating.id,
|
||||
type: "number",
|
||||
label: "Rating",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
team: {
|
||||
connect: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: form.name,
|
||||
},
|
||||
});
|
||||
console.log(`🎯 Created form ${createdForm.id}`, JSON.stringify(createdForm, null, 2));
|
||||
return createdForm;
|
||||
} else {
|
||||
// Default routing form
|
||||
const multiSelectOption2Uuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectOption1Uuid = "d1292635-9f12-17b1-9153-c3a854649182";
|
||||
const selectOption1Uuid = "d0292635-9f12-17b1-9153-c3a854649182";
|
||||
const selectOption2Uuid = "d0302635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectLegacyFieldUuid = "d4292635-9f12-17b1-9153-c3a854649182";
|
||||
const multiSelectFieldUuid = "d9892635-9f12-17b1-9153-c3a854649182";
|
||||
const selectFieldUuid = "d1302635-9f12-17b1-9153-c3a854649182";
|
||||
const legacySelectFieldUuid = "f0292635-9f12-17b1-9153-c3a854649182";
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
routes: config.form?.routes || [
|
||||
{
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
action: { type: "eventTypeRedirectUrl", value: "pro/30min" },
|
||||
queryValue: {
|
||||
id: "8a898988-89ab-4cde-b012-31823f708642",
|
||||
type: "group",
|
||||
children1: {
|
||||
"8988bbb8-0123-4456-b89a-b1823f70c5ff": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["event-routing"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
action: { type: "customPageMessage", value: "Custom Page Result" },
|
||||
queryValue: {
|
||||
id: "aa8aaba9-cdef-4012-b456-71823f70f7ef",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b99b8a89-89ab-4cde-b012-31823f718ff5": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["custom-page"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
children1: {
|
||||
"998b9b9a-0123-4456-b89a-b1823f7232b9": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
value: ["external-redirect"],
|
||||
operator: "equal",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect(Legacy) chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectLegacyFieldUuid,
|
||||
value: [["Option-2"]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "bb9ea8b9-0123-4456-b89a-b182623406d8",
|
||||
action: { type: "customPageMessage", value: "Multiselect chosen" },
|
||||
queryValue: {
|
||||
id: "aa8ba8b9-0123-4456-b89a-b182623406d8",
|
||||
type: "group",
|
||||
children1: {
|
||||
"b98a8abb-cdef-4012-b456-718262343d27": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: multiSelectFieldUuid,
|
||||
value: [[multiSelectOption2Uuid]],
|
||||
operator: "multiselect_equals",
|
||||
valueSrc: ["value"],
|
||||
valueType: ["multiselect"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "898899aa-4567-489a-bcde-f1823f708646",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "898899aa-4567-489a-bcde-f1823f708646", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: config.form?.fields || [
|
||||
{
|
||||
id: "c4296635-9f12-47b1-8153-c3a854649182",
|
||||
type: "text",
|
||||
label: "Test field",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: multiSelectLegacyFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select(with Legacy `selectText`)",
|
||||
identifier: "multi",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: multiSelectFieldUuid,
|
||||
type: "multiselect",
|
||||
label: "Multi Select",
|
||||
identifier: "multi-new-format",
|
||||
options: [
|
||||
{
|
||||
id: multiSelectOption1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: multiSelectOption2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: legacySelectFieldUuid,
|
||||
type: "select",
|
||||
label: "Legacy Select",
|
||||
identifier: "test-select",
|
||||
selectText: "Option-1\nOption-2",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: selectFieldUuid,
|
||||
type: "select",
|
||||
label: "Select",
|
||||
identifier: "test-select-new-format",
|
||||
options: [
|
||||
{
|
||||
id: selectOption1Uuid,
|
||||
label: "Option-1",
|
||||
},
|
||||
{
|
||||
id: selectOption2Uuid,
|
||||
label: "Option-2",
|
||||
},
|
||||
],
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
name: config.form?.name || "Fixture Routing Form",
|
||||
},
|
||||
});
|
||||
|
||||
return form;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Prisma as PrismaType } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
/**
|
||||
* Creates a team event type and assigns all team members as hosts
|
||||
* @param teamId - The team ID
|
||||
* @param eventType - Event type data to create
|
||||
* @returns The created event type
|
||||
*/
|
||||
export async function createRoundRobinTeamEventType({
|
||||
teamId,
|
||||
eventType,
|
||||
}: {
|
||||
teamId: number;
|
||||
eventType: PrismaType.EventTypeCreateInput;
|
||||
}) {
|
||||
const createdEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
...eventType,
|
||||
team: {
|
||||
connect: {
|
||||
id: teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
team: true,
|
||||
},
|
||||
});
|
||||
|
||||
const teamMemberships = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
// Add all team members as hosts (excluding the current user who's already added)
|
||||
for (const membership of teamMemberships) {
|
||||
await prisma.host.create({
|
||||
data: {
|
||||
userId: membership.userId,
|
||||
eventTypeId: createdEventType.id,
|
||||
isFixed: false, // ROUND_ROBIN scheduling type
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return createdEventType;
|
||||
}
|
||||
@@ -75,6 +75,7 @@
|
||||
"you_can_view_booking_details_with_this_url": "You can view the booking details from this url {{url}} and add the event to your calendar",
|
||||
"no_options_available": "No options available",
|
||||
"cancellation_reason": "Reason for cancellation (optional)",
|
||||
"cancelled_by":"Cancelled By",
|
||||
"cancellation_reason_host": "Reason for cancellation",
|
||||
"cancellation_reason_placeholder": "Why are you cancelling?",
|
||||
"notify_attendee_cancellation_reason_warning": "Cancellation reason will be shared with guests",
|
||||
@@ -3361,5 +3362,6 @@
|
||||
"license_validation_failed": "Failed to validate license key",
|
||||
"license_key_saved": "License key saved successfully",
|
||||
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
|
||||
"event_type_no_hosts": "No hosts are assigned to event type",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
+1199
-56
File diff suppressed because it is too large
Load Diff
@@ -60,7 +60,7 @@ use the `<CreateEventType />` component while passing `teamId={teamId}` to it to
|
||||
```js
|
||||
import { CreateEventType } from "@calcom/atoms";
|
||||
|
||||
export default function TeamEventType(teamId: number) {l
|
||||
export default function TeamEventType(teamId: number) {
|
||||
return (
|
||||
<>
|
||||
<CreateEventType
|
||||
@@ -279,4 +279,4 @@ The payments tab lets you accept payments for your events. At the moment we only
|
||||
|
||||
Once you connect your Stripe account, you can set a custom price along with the currency and payment option; which will determine when to charge your customers.
|
||||
|
||||
<img src="/images/StripeConnected.png" alt="Payments tab with stripe connected" />
|
||||
<img src="/images/StripeConnected.png" alt="Payments tab with stripe connected" />
|
||||
|
||||
@@ -88,7 +88,12 @@ export function getAbsoluteEventTypeRedirectUrl({
|
||||
}
|
||||
}
|
||||
|
||||
const origin = teamSlugInRedirectUrl ? form.teamOrigin : form.userOrigin;
|
||||
// We want origin to be the WEBAPP_URL as in E2E the org domain won't exist. E2E fake request from org domain using doOnOrgDomain
|
||||
const origin = process.env.NEXT_PUBLIC_IS_E2E
|
||||
? WEBAPP_URL
|
||||
: teamSlugInRedirectUrl
|
||||
? form.teamOrigin
|
||||
: form.userOrigin;
|
||||
|
||||
return `${origin}/${eventTypeRedirectUrl}?${allURLSearchParams}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Frame } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
export async function expectHostsToBe({ hosts, frame }: { hosts: { email: string }[]; frame: Frame }) {
|
||||
try {
|
||||
await frame.waitForSelector(`[data-testid="booking-host-email"]`, { timeout: 5000 });
|
||||
} catch (error) {
|
||||
console.error("Failed to find booking-host-email element:", error.message);
|
||||
throw new Error(`booking-host-email element not found. `);
|
||||
}
|
||||
|
||||
const hostEmailsOnPage = await frame.locator(`[data-testid="booking-host-email"]`).allTextContents();
|
||||
const expectedEmails = hosts.map((host) => host.email);
|
||||
|
||||
expect(hostEmailsOnPage).toEqual(expect.arrayContaining(expectedEmails));
|
||||
}
|
||||
@@ -3,6 +3,39 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export async function getQueuedFormResponse(queuedFormResponseId: string) {
|
||||
return prisma.app_RoutingForms_QueuedFormResponse.findFirst({
|
||||
where: {
|
||||
id: queuedFormResponseId,
|
||||
},
|
||||
include: {
|
||||
actualResponse: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllFormResponses(formId: string) {
|
||||
return prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: formId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLatestQueuedFormResponse({ formId }: { formId: string }) {
|
||||
return prisma.app_RoutingForms_QueuedFormResponse.findFirst({
|
||||
where: {
|
||||
formId: formId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const deleteAllBookingsByEmail = async (email: string) =>
|
||||
await prisma.booking.deleteMany({
|
||||
where: {
|
||||
@@ -115,6 +148,10 @@ export async function bookFirstEvent(username: string, frame: Frame, page: Page)
|
||||
await frame.waitForTimeout(1000);
|
||||
// expect(await page.screenshot()).toMatchSnapshot("availability-page-1.png");
|
||||
// Remove /embed from the end if present.
|
||||
return bookEvent({ frame, page });
|
||||
}
|
||||
|
||||
export async function bookEvent({ frame, page }: { frame: Frame; page: Page }) {
|
||||
const eventSlug = new URL(frame.url()).pathname.replace(/\/embed$/, "");
|
||||
await selectFirstAvailableTimeSlotNextMonth(frame, page);
|
||||
// expect(await page.screenshot()).toMatchSnapshot("booking-page.png");
|
||||
@@ -158,3 +195,40 @@ export async function assertNoRequestIsBlocked(page: Page) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectEmbedIFrameToBeVisible({
|
||||
calNamespace,
|
||||
page,
|
||||
}: {
|
||||
calNamespace: string;
|
||||
page: Page;
|
||||
}) {
|
||||
const iframe = page.locator(`[name="cal-embed=${calNamespace}"]`);
|
||||
await expect(iframe).toBeVisible();
|
||||
}
|
||||
|
||||
export async function expectActualFormResponseConnectedToQueuedFormResponse({
|
||||
queuedFormResponse,
|
||||
page,
|
||||
numberOfExpectedSetFieldValues,
|
||||
}: {
|
||||
queuedFormResponse: { id: string };
|
||||
page: Page;
|
||||
}) {
|
||||
const responsePromise = page.waitForResponse("**/queued-response");
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const queuedFormResponseFromDb = await getQueuedFormResponse(queuedFormResponse.id);
|
||||
|
||||
expect(queuedFormResponseFromDb?.actualResponse?.id).toBeDefined();
|
||||
const responseFromDb = queuedFormResponseFromDb?.actualResponse?.response;
|
||||
expect(responseFromDb).toBeDefined();
|
||||
const valuesFromResponse = Object.values(responseFromDb).map((item) => item.value);
|
||||
const valuesSetInResponse = valuesFromResponse.filter((value) => !!value);
|
||||
// We are unable to verify the exact response values because we don't directly have the form field identifiers in responseFromDB and would require correlating that with the actual Form from DB
|
||||
// So, for now we just verify the number of values set
|
||||
// There are 5 values that are submitted when CTA is clicked.
|
||||
// TODO: We should be able to verify the exact response values by correlating the form field identifiers with the actual Form from DB
|
||||
expect(valuesSetInResponse.length).toBe(numberOfExpectedSetFieldValues);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/playwright/lib/fixtures";
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
import { doOnOrgDomain } from "@calcom/web/playwright/lib/testUtils";
|
||||
|
||||
import { expectHostsToBe } from "../lib/pages/bookingSuccessPage";
|
||||
import {
|
||||
getEmbedIframe,
|
||||
bookEvent,
|
||||
expectEmbedIFrameToBeVisible,
|
||||
getAllFormResponses,
|
||||
expectActualFormResponseConnectedToQueuedFormResponse,
|
||||
getLatestQueuedFormResponse,
|
||||
} from "../lib/testUtils";
|
||||
|
||||
// in parallel mode sometimes handleNewBooking endpoint throws "No available users found" error, this never happens in serial mode.
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
async function fillRoutingRequiredAttributes(
|
||||
page: Page,
|
||||
{ skills, location, email }: { skills: string; location: string; email: string }
|
||||
) {
|
||||
await page.fill("#cal-booking-place-routingFormFullPrerender-input-email", email);
|
||||
await page.selectOption("#cal-booking-place-routingFormFullPrerender-select-skills", skills);
|
||||
await page.selectOption("#cal-booking-place-routingFormFullPrerender-select-location", location);
|
||||
|
||||
return {
|
||||
skills,
|
||||
location,
|
||||
email,
|
||||
};
|
||||
}
|
||||
|
||||
async function fillRestOfTheFormAndSubmit(page: Page) {
|
||||
const name = "John Doe";
|
||||
await page.fill("#cal-booking-place-routingFormFullPrerender-input-name", name);
|
||||
await page.click("#cta-routingFormFullPrerender");
|
||||
|
||||
return {
|
||||
name,
|
||||
};
|
||||
}
|
||||
|
||||
//TODO: Change these tests to use a user/eventType per embed type atleast. This is so that we can test different themes,layouts configured in App or per EventType
|
||||
test.describe("Prerender Headless Router", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
test("should prerender when routing-required fields are filled and route to Javascript team as Javascript skills is selected and then doing the actual booking should show the routed team members as hosts", async ({
|
||||
page,
|
||||
embeds,
|
||||
users,
|
||||
}) => {
|
||||
const userFixture = await users.create(
|
||||
{ username: "routing-forms" },
|
||||
{
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
seedRoutingFormWithAttributeRouting: true,
|
||||
seedRoutingForms: true,
|
||||
}
|
||||
);
|
||||
|
||||
const orgMembership = await userFixture.getOrgMembership();
|
||||
const teamMembership = await userFixture.getFirstTeamMembership();
|
||||
const routingForm = userFixture.routingForms[0];
|
||||
|
||||
const calNamespace = "routingFormFullPrerender";
|
||||
await embeds.gotoPlayground({
|
||||
calNamespace,
|
||||
url: `/embed/routing-playground.html?only=ns:${calNamespace}¶m.formId=${routingForm.id}&calOrigin=${WEBAPP_URL}&cal.embed.logging=1`,
|
||||
});
|
||||
|
||||
await doOnOrgDomain({ orgSlug: orgMembership.team.slug, page }, async ({ page }) => {
|
||||
const { skills, location, email } = await fillRoutingRequiredAttributes(page, {
|
||||
skills: "JavaScript",
|
||||
location: "London",
|
||||
email: "embed-user@example.com", // This is the email that will be used to book the event
|
||||
});
|
||||
|
||||
const expectedRoutedTeamMembers = [teamMembership.user];
|
||||
// Routes to the team booking page with params cal.routingFormResponseId, cal.routedTeamMemberIds
|
||||
// Sample URL: http://app.cal.local:3000/team/team-0-1751270109394/team-javascript/embed?cal.teamId=103&cal.orgId=104&Rating=5&embed=routingFormFullPrerender&debug=true&only=ns%3AroutingFormFullPrerender¶m.formId=bed2a37b-8bf7-4eb9-9b9e-4adf33bd9470&calOrigin=http%3A%2F%2Fapp.cal.remote%3A3000&cal.embed.logging=1&cal.embed.pageType=team.event.booking.slots&cal.action=eventTypeRedirectUrl&cal.queuedFormResponseId=cmcit0aos0006j5o4qctc2oyy&cal.embed.connectVersion=0&form=bed2a37b-8bf7-4eb9-9b9e-4adf33bd9470&skills=JavaScript&Location=London&name=John+Doe&Email=undefined&Manager=John+Doe&embedType=modal&guest=undefined&cal.routingFormResponseId=14&month=2025-07&date=2025-07-02&slot=2025-07-02T08%3A00%3A00.000Z
|
||||
const { prerenderedIframe, queuedFormResponseId } = await expectPrerenderedRoutedTeamBookingPage({
|
||||
page,
|
||||
calNamespace,
|
||||
calLink: `/team/${teamMembership.team.slug}/team-javascript`,
|
||||
embeds,
|
||||
routedTeamMemberIds: expectedRoutedTeamMembers.map((user) => user.id),
|
||||
routingForm,
|
||||
});
|
||||
|
||||
const { name } = await fillRestOfTheFormAndSubmit(page);
|
||||
await Promise.all([
|
||||
expectEmbedIFrameToBeVisible({ calNamespace, page }),
|
||||
expectActualFormResponseConnectedToQueuedFormResponse({
|
||||
queuedFormResponse: { id: queuedFormResponseId },
|
||||
numberOfExpectedSetFieldValues: 5,
|
||||
page,
|
||||
}),
|
||||
]);
|
||||
await bookEvent({ frame: prerenderedIframe, page });
|
||||
await expectHostsToBe({
|
||||
hosts: expectedRoutedTeamMembers.map((user) => ({ email: user.email })),
|
||||
frame: prerenderedIframe,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function expectPrerenderedRoutedTeamBookingPage({
|
||||
page,
|
||||
calNamespace,
|
||||
calLink,
|
||||
embeds,
|
||||
routingForm,
|
||||
routedTeamMemberIds,
|
||||
}: {
|
||||
page: Page;
|
||||
calNamespace: string;
|
||||
calLink: string;
|
||||
embeds: Fixtures["embeds"];
|
||||
routingForm: {
|
||||
id: string;
|
||||
};
|
||||
routedTeamMemberIds: string[];
|
||||
}) {
|
||||
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
|
||||
|
||||
if (!prerenderedIframe) {
|
||||
throw new Error("Prerendered iframe not found");
|
||||
}
|
||||
|
||||
// As Routing Form is unique to the test, we can expect responses to be provided by the test itself.
|
||||
// So, the only response should be the one that is provided by the test itself.
|
||||
const queuedResponse = await getLatestQueuedFormResponse({ formId: routingForm.id });
|
||||
|
||||
const routingFormResponses = await getAllFormResponses(routingForm.id);
|
||||
|
||||
if (!queuedResponse) {
|
||||
throw new Error("Couldn't find the queued response");
|
||||
}
|
||||
|
||||
if (routingFormResponses.length > 0) {
|
||||
throw new Error(
|
||||
`Routing form response shouldn't have been recorded. Found one with id: ${routingFormResponses[0].id}`
|
||||
);
|
||||
}
|
||||
|
||||
await expect(prerenderedIframe).toBeEmbedCalLink(
|
||||
calNamespace,
|
||||
embeds.getActionFiredDetails,
|
||||
{
|
||||
pathname: calLink,
|
||||
searchParams: {
|
||||
"cal.queuedFormResponseId": queuedResponse.id,
|
||||
"cal.routedTeamMemberIds": routedTeamMemberIds.join(","),
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
return { prerenderedIframe, queuedFormResponseId: queuedResponse.id };
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
const newSearchParams = new URLSearchParams(location.search);
|
||||
let paramsChanged = false;
|
||||
if (!newSearchParams.has("only")) {
|
||||
newSearchParams.set("only", "ns:routingFormPrerender");
|
||||
newSearchParams.set("only", "ns:routingFormFullPrerender");
|
||||
paramsChanged = true;
|
||||
}
|
||||
|
||||
@@ -116,56 +116,15 @@
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Rating=5&skills=${skills}&Email=${email}`);
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<div id="cal-booking-place-routingFormPrerender">
|
||||
<div id="cal-booking-routingFormFullPrerender">
|
||||
<hr/>
|
||||
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Team Booking Page - Demo</a>
|
||||
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
|
||||
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal, sending fresh request for headless router, followed by fetching the slots</p>
|
||||
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
|
||||
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as slotsStaleTimeMs threshold is not breached). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
|
||||
<form id="cal-booking-place-routingFormPrerender-form">
|
||||
<input type="text" name="name" placeholder="John Doe" />
|
||||
<input type="email" name="email" placeholder="john@example.com" />
|
||||
<select name="skills" placeholder="JavaScript, Node.js">
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
</form>
|
||||
<button id="cal-booking-place-routingFormPrerender-submit" data-cal-namespace="routingFormPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
|
||||
<script>
|
||||
document.getElementById("cal-booking-place-routingFormPrerender-form").addEventListener("input", function prerender(e) {
|
||||
if (prerender.initiated) return;
|
||||
prerender.initiated = true;
|
||||
console.log("Playground: prerender initiated");
|
||||
Cal.ns.routingFormPrerender("prerender", {
|
||||
calLink: "team/insights-team/team-javascript",
|
||||
type: "modal",
|
||||
pageType: "user.event.booking.slots",
|
||||
});
|
||||
});
|
||||
|
||||
requestAnimationFrame(function updateSubmitButtonLink() {
|
||||
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
|
||||
const form = document.getElementById("cal-booking-place-routingFormPrerender-form");
|
||||
const name = form.querySelector("input[name='name']").value;
|
||||
const email = form.querySelector("input[name='email']").value;
|
||||
const skills = form.querySelector("select[name='skills']").value;
|
||||
if (name && email) {
|
||||
document.getElementById("cal-booking-place-routingFormPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
|
||||
}
|
||||
requestAnimationFrame(updateSubmitButtonLink);
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
<div id="cal-booking-routing-full-prerender">
|
||||
<hr/>
|
||||
<a href="?only=ns:routing-full-prerender&debug=1¶m.email=test@example.com">Routing Form - Prerender Headless Router itself queuing the form response</a>
|
||||
<a href="?only=ns:routingFormFullPrerender&debug=1¶m.email=test@example.com">Routing Form - Prerender Headless Router itself queuing the form response</a>
|
||||
<p>1. As page is loading, we prerender the headless router for the email passed as param.email</p>
|
||||
<p>2. Whenever email changes and onblur happens, the prerender is triggered for the new email and previous prerendered modal is removed</p>
|
||||
<p>3. The prerender of the headless router queues the form response and doesn't really record it. When the actual booking is made, the form response is recorded from the queue</p>
|
||||
@@ -174,36 +133,73 @@
|
||||
<p>6. slotsStaleTimeMs is set to 10 seconds(default is 1 minute) and iframeForceReloadThresholdMs is set to 30 seconds(default is 15 minutes) and they are considered from the time when the prerender/modal call was made</p>
|
||||
<p>7. To avoid reaching the iframeForceReloadThresholdMs, user could prerender the router again and again judiciously</p>
|
||||
</p>
|
||||
<script>
|
||||
window.buildRouterUrl = ({email, formId}) => {
|
||||
return `router?form=${formId}&email=${email}`;
|
||||
}
|
||||
</script>
|
||||
<div class="inline-embed-container">
|
||||
<div id="cal-booking-place-routing-full-prerender">
|
||||
<div class="place"></div>
|
||||
<label for="cal-booking-place-routing-full-prerender-input-email">Email</label>
|
||||
<script>
|
||||
document.write(`<input style="width: 400px" type="email" id="cal-booking-place-routing-full-prerender-input-email" value="${window.params.email ?? ''}" />`)
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
<h3><script>
|
||||
document.write(`<button id="cta-routing-full-prerender" data-cal-namespace="routingFormFullPrerender" data-cal-origin="${window.calOrigin}" data-cal-link="${window.buildRouterUrl({email:document.getElementById('cal-booking-place-routing-full-prerender-input-email').value, formId:window.params.formId})}" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots"}'>Demo</button>`)
|
||||
</script></h3>
|
||||
<script>
|
||||
(()=>{
|
||||
if (window.only !== "ns:routing-full-prerender") {
|
||||
<script>
|
||||
(()=>{
|
||||
if (window.only !== "ns:routingFormFullPrerender") {
|
||||
return;
|
||||
}
|
||||
Cal("init", "routingFormFullPrerender", {
|
||||
debug: true,
|
||||
calOrigin: window.calOrigin,
|
||||
});
|
||||
const prerender = ({email, formId}) => {
|
||||
window.routingFormFullPrerender = {
|
||||
buildRouterUrl: ({skills, location, name, email, formId}) => {
|
||||
return `router?form=${formId}&skills=${skills}&Location=${location}&name=${name}&Email=${email}&Rating=5`;
|
||||
},
|
||||
onFormSubmit: (e) => {
|
||||
e.preventDefault();
|
||||
const skills = document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').value;
|
||||
const location = document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').value;
|
||||
const name = document.getElementById('cal-booking-place-routingFormFullPrerender-input-name').value;
|
||||
const email = document.getElementById('cal-booking-place-routingFormFullPrerender-input-email').value;
|
||||
const routerUrl = window.routingFormFullPrerender.buildRouterUrl({skills, location, name, email, formId: window.params.formId});
|
||||
Cal.ns.routingFormFullPrerender("modal", {
|
||||
calLink: routerUrl,
|
||||
calOrigin: window.calOrigin,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<div id="cal-booking-place-routingFormFullPrerender">
|
||||
<div class="place"></div>
|
||||
<form id="cal-booking-place-routingFormFullPrerender-form" onsubmit="window.routingFormFullPrerender.onFormSubmit(event)">
|
||||
<label for="cal-booking-place-routingFormFullPrerender-input-name">Name</label>
|
||||
<input required style="width: 400px" type="text" id="cal-booking-place-routingFormFullPrerender-input-name" placeholder="John Doe" />
|
||||
<label for="cal-booking-place-routingFormFullPrerender-input-email">Email</label>
|
||||
<input required style="width: 400px" type="email" id="cal-booking-place-routingFormFullPrerender-input-email" placeholder="john@example.com" />
|
||||
<label for="cal-booking-place-routingFormFullPrerender-select-skills">Skills</label>
|
||||
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-skills">
|
||||
<option value="">Select a skill</option>
|
||||
<option value="JavaScript">JavaScript</option>
|
||||
<option value="React">React</option>
|
||||
<option value="Node.js">Node.js</option>
|
||||
<option value="Python">Python</option>
|
||||
<option value="Sales">Sales</option>
|
||||
</select>
|
||||
|
||||
<label for="cal-booking-place-routingFormFullPrerender-select-location">Location</label>
|
||||
<select required style="width: 400px" id="cal-booking-place-routingFormFullPrerender-select-location">
|
||||
<option value="">Select a location</option>
|
||||
<option value="New York">New York</option>
|
||||
<option value="London">London</option>
|
||||
<option value="Tokyo">Tokyo</option>
|
||||
<option value="Berlin">Berlin</option>
|
||||
<option value="Remote">Remote</option>
|
||||
</select>
|
||||
<button type="submit" id="cta-routingFormFullPrerender">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(()=>{
|
||||
const prerender = ({skills, location, email, formId}) => {
|
||||
Cal.ns.routingFormFullPrerender("prerender", {
|
||||
// Email will be automatically passed as query param
|
||||
calLink: window.buildRouterUrl({email, formId}),
|
||||
calLink: window.routingFormFullPrerender.buildRouterUrl({skills, location, email, formId}),
|
||||
type: "modal",
|
||||
pageType: "team.event.booking.slots",
|
||||
options: {
|
||||
@@ -212,26 +208,23 @@
|
||||
},
|
||||
});
|
||||
}
|
||||
document.getElementById('cal-booking-place-routing-full-prerender-input-email').onchange = function onEmailChange(e) {
|
||||
const element = e.target;
|
||||
const email = element.validity.valid ? element.value : null;
|
||||
if (!email) {
|
||||
|
||||
function onSelectChange(e) {
|
||||
const skills = document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').value;
|
||||
const location = document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').value;
|
||||
const email = document.getElementById('cal-booking-place-routingFormFullPrerender-input-email').value;
|
||||
function areFieldRequiredbyRoutingFormAvailable() {
|
||||
return skills && location;
|
||||
}
|
||||
|
||||
if (!areFieldRequiredbyRoutingFormAvailable()) {
|
||||
return;
|
||||
}
|
||||
function updateCtaLink(email) {
|
||||
const element = document.getElementById('cta-routing-full-prerender');
|
||||
const currentLink = element.getAttribute('data-cal-link');
|
||||
const currentLinkObject = new URL(currentLink, 'https://example.com');
|
||||
currentLinkObject.searchParams.set('email', email);
|
||||
element.setAttribute('data-cal-link', currentLinkObject.pathname + currentLinkObject.search);
|
||||
}
|
||||
updateCtaLink(email);
|
||||
prerender({email, formId:window.params.formId});
|
||||
|
||||
prerender({skills, location, email, formId: window.params.formId});
|
||||
}
|
||||
if (!window.params.email || !window.params.formId) {
|
||||
alert('Pass param.email and param.formId query params')
|
||||
}
|
||||
prerender({email:window.params.email, formId:window.params.formId});
|
||||
document.getElementById('cal-booking-place-routingFormFullPrerender-select-skills').onchange = onSelectChange;
|
||||
document.getElementById('cal-booking-place-routingFormFullPrerender-select-location').onchange = onSelectChange;
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
|
||||
@@ -96,6 +96,7 @@ export async function getServerSession(options: {
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
email_verified: user.emailVerified !== null,
|
||||
completedOnboarding: user.completedOnboarding,
|
||||
role: user.role,
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: user.avatarUrl,
|
||||
|
||||
@@ -19,6 +19,7 @@ const getUserBooking = async (uid: string) => {
|
||||
status: true,
|
||||
metadata: true,
|
||||
cancellationReason: true,
|
||||
cancelledBy: true,
|
||||
responses: true,
|
||||
rejectionReason: true,
|
||||
userPrimaryEmail: true,
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
enrichHostsWithDelegationCredentials,
|
||||
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getEventName } from "@calcom/lib/event";
|
||||
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
@@ -99,6 +100,10 @@ export const roundRobinReassignment = async ({
|
||||
createdAt: new Date(0), // use earliest possible date as fallback
|
||||
}));
|
||||
|
||||
if (eventType.hosts.length === 0) {
|
||||
throw new Error(ErrorCode.EventTypeNoHosts);
|
||||
}
|
||||
|
||||
const roundRobinHosts = eventType.hosts.filter((host) => !host.isFixed);
|
||||
|
||||
const originalOrganizer = booking.user;
|
||||
|
||||
+2
-1
@@ -104,9 +104,10 @@ export default function RequiresConfirmationController({
|
||||
LockedIcon={requiresConfirmationLockedProps.LockedIcon}
|
||||
onCheckedChange={(val) => {
|
||||
formMethods.setValue("requiresConfirmation", val, { shouldDirty: true });
|
||||
// If we uncheck requires confirmation, we also uncheck the "will block slot" checkbox
|
||||
// If we uncheck requires confirmation, we also uncheck these checkboxes
|
||||
if (!val) {
|
||||
formMethods.setValue("requiresConfirmationWillBlockSlot", false, { shouldDirty: true });
|
||||
formMethods.setValue("requiresConfirmationForFreeEmail", false, { shouldDirty: true });
|
||||
}
|
||||
onRequiresConfirmation(val);
|
||||
}}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type {
|
||||
ArrayPath,
|
||||
Control,
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
UseFieldArrayRemove,
|
||||
} from "react-hook-form";
|
||||
import { Controller, useFieldArray, useFormContext } from "react-hook-form";
|
||||
import type { GroupBase, Props } from "react-select";
|
||||
import { createFilter, type GroupBase, type Props } from "react-select";
|
||||
|
||||
import type { scheduleClassNames } from "@calcom/atoms/availability/types";
|
||||
import type { ConfigType } from "@calcom/dayjs";
|
||||
@@ -20,6 +20,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
|
||||
import type { TimeRange } from "@calcom/types/schedule";
|
||||
|
||||
import cn from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dropdown, DropdownMenuContent, DropdownMenuTrigger } from "@calcom/ui/components/dropdown";
|
||||
@@ -396,9 +397,36 @@ const LazySelect = ({
|
||||
filter({ current: value });
|
||||
}, [filter, value]);
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const defaultFilter = React.useMemo(() => createFilter(), []);
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const regex = /^(\d{1,2})(a|p|am|pm)$/i;
|
||||
const match = inputValue.replaceAll(" ", "").match(regex);
|
||||
if (!match) {
|
||||
return options.filter((option) =>
|
||||
defaultFilter({ ...option, data: option.label, value: option.label }, inputValue)
|
||||
);
|
||||
}
|
||||
|
||||
const [, numberPart, periodPart] = match;
|
||||
const periodLower = periodPart.toLowerCase();
|
||||
const scoredOptions = options
|
||||
.filter((option) => option.label && option.label.toLowerCase().includes(periodLower))
|
||||
.map((option) => {
|
||||
const labelLower = option.label.toLowerCase();
|
||||
const index = labelLower.indexOf(numberPart);
|
||||
const score = index >= 0 ? index + labelLower.length : Infinity;
|
||||
return { score, option };
|
||||
})
|
||||
.sort((a, b) => a.score - b.score);
|
||||
|
||||
const maxScore = scoredOptions[0]?.score;
|
||||
return scoredOptions.filter((item) => item.score === maxScore).map((item) => item.option);
|
||||
}, [inputValue, options, defaultFilter]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
options={options}
|
||||
options={filteredOptions}
|
||||
onMenuOpen={() => {
|
||||
if (min) filter({ offset: min });
|
||||
if (max) filter({ limit: max });
|
||||
@@ -408,6 +436,8 @@ const LazySelect = ({
|
||||
value={options.find((option) => option.value === dayjs(value).toDate().valueOf())}
|
||||
onMenuClose={() => filter({ current: value })}
|
||||
components={{ DropdownIndicator: () => null, IndicatorSeparator: () => null }}
|
||||
onInputChange={setInputValue}
|
||||
filterOption={() => true}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -67,7 +67,7 @@ function UpgradeTeamTip() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AvailabilitySliderTable(props: { userTimeFormat: number | null; isOrg: boolean }) {
|
||||
export function AvailabilitySliderTable(props: { isOrg: boolean }) {
|
||||
return (
|
||||
<DataTableProvider>
|
||||
<AvailabilitySliderTableContent {...props} />
|
||||
@@ -75,7 +75,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null;
|
||||
);
|
||||
}
|
||||
|
||||
function AvailabilitySliderTableContent(props: { userTimeFormat: number | null; isOrg: boolean }) {
|
||||
function AvailabilitySliderTableContent(props: { isOrg: boolean }) {
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [browsingDate, setBrowsingDate] = useState(dayjs());
|
||||
const [editSheetOpen, setEditSheetOpen] = useState(false);
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import type { DestinationCalendar } from "@prisma/client";
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import EventManager from "./EventManager";
|
||||
|
||||
vi.mock("@calcom/lib/crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
|
||||
|
||||
function buildCalDAVCredential(data: {
|
||||
id: number;
|
||||
key: string;
|
||||
userId?: number;
|
||||
}): CredentialForCalendarService {
|
||||
return {
|
||||
id: data.id,
|
||||
type: "caldav_calendar",
|
||||
key: data.key,
|
||||
userId: data.userId || 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "caldav-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDestinationCalendar(data: {
|
||||
id: number;
|
||||
integration: string;
|
||||
externalId: string | null;
|
||||
}): DestinationCalendar {
|
||||
return {
|
||||
id: data.id,
|
||||
integration: data.integration,
|
||||
externalId: data.externalId || "",
|
||||
userId: 1,
|
||||
eventTypeId: null,
|
||||
credentialId: null,
|
||||
primaryEmail: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("EventManager CalDAV credential validation", () => {
|
||||
let eventManager: EventManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
eventManager = new EventManager({
|
||||
credentials: [],
|
||||
destinationCalendar: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractServerUrlFromCredential", () => {
|
||||
it("should extract server URL from valid CalDAV credential", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("https://caldav.example.com");
|
||||
expect(mockedSymmetricDecrypt).toHaveBeenCalledWith(
|
||||
"encrypted_key",
|
||||
process.env.CALENDSO_ENCRYPTION_KEY || ""
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for non-CalDAV credential", () => {
|
||||
const credential = {
|
||||
...buildCalDAVCredential({ id: 1, key: "encrypted_key" }),
|
||||
type: "google_calendar",
|
||||
} as CredentialForCalendarService;
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
expect(mockedSymmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return null when decryption fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "invalid_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when decrypted data has no URL", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when URL is invalid", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "invalid-url",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com:8443/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("https://caldav.example.com:8443");
|
||||
});
|
||||
|
||||
it("should handle HTTP URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "http://caldav.internal.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromCredential(credential);
|
||||
expect(result).toBe("http://caldav.internal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractServerUrlFromDestination", () => {
|
||||
it("should extract server URL from CalDAV destination calendar", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("https://caldav.example.com");
|
||||
});
|
||||
|
||||
it("should return null for non-CalDAV destination", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when externalId is null", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: null,
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when externalId is invalid URL", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "invalid-url",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com:8443/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("https://caldav.example.com:8443");
|
||||
});
|
||||
|
||||
it("should handle HTTP URLs", () => {
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "http://caldav.internal.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).extractServerUrlFromDestination(destination);
|
||||
expect(result).toBe("http://caldav.internal.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("credentialMatchesDestination", () => {
|
||||
it("should return true for matching CalDAV server URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for non-matching CalDAV server URLs", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://different.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for non-CalDAV credentials", () => {
|
||||
const credential = {
|
||||
...buildCalDAVCredential({ id: 1, key: "encrypted_key" }),
|
||||
type: "google_calendar",
|
||||
} as CredentialForCalendarService;
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
expect(symmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return true when credential is CalDAV but destination is not", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "google_calendar",
|
||||
externalId: "calendar@gmail.com",
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
expect(symmetricDecrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return false when credential URL extraction fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "invalid_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false when destination URL extraction fails", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "invalid-url",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle URLs with different paths but same server", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/different/path/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle URLs with different ports as non-matching", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com:8443/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "https://caldav.example.com:9443/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle HTTP vs HTTPS as non-matching", () => {
|
||||
const credential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_key",
|
||||
});
|
||||
|
||||
const destination = buildDestinationCalendar({
|
||||
id: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: "https://caldav.example.com/dav/calendars/user/calendar1/",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "http://caldav.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const result = (eventManager as any).credentialMatchesDestination(credential, destination);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@ import getApps from "@calcom/app-store/utils";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getUid } from "@calcom/lib/CalEventParser";
|
||||
import CRMScheduler from "@calcom/lib/crmManager/tasker/crmScheduler";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import {
|
||||
getPiiFreeDestinationCalendar,
|
||||
@@ -40,6 +41,8 @@ import { isDelegationCredential } from "./delegationCredential/clientAndServer";
|
||||
import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["EventManager"] });
|
||||
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
const CALDAV_CALENDAR_TYPE = "caldav_calendar";
|
||||
export const isDedicatedIntegration = (location: string): boolean => {
|
||||
return location !== MeetLocationType && location.includes("integrations:");
|
||||
};
|
||||
@@ -172,6 +175,82 @@ export default class EventManager {
|
||||
this.appOptions = eventTypeAppMetadata;
|
||||
}
|
||||
|
||||
private extractServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
|
||||
try {
|
||||
if (credential.type !== CALDAV_CALENDAR_TYPE) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
if (!decryptedData.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(decryptedData.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
log.warn("Failed to extract server URL from CalDAV credential", {
|
||||
credentialId: credential.id,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractServerUrlFromDestination(destination: DestinationCalendar): string | null {
|
||||
try {
|
||||
if (destination.integration !== CALDAV_CALENDAR_TYPE || !destination.externalId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(destination.externalId);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
log.warn("Failed to extract server URL from destination calendar", {
|
||||
destinationId: destination.id,
|
||||
externalId: destination.externalId,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private credentialMatchesDestination(
|
||||
credential: CredentialForCalendarService,
|
||||
destination: DestinationCalendar
|
||||
): boolean {
|
||||
if (credential.type !== CALDAV_CALENDAR_TYPE || destination.integration !== CALDAV_CALENDAR_TYPE) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const credentialServerUrl = this.extractServerUrlFromCredential(credential);
|
||||
const destinationServerUrl = this.extractServerUrlFromDestination(destination);
|
||||
|
||||
if (!credentialServerUrl || !destinationServerUrl) {
|
||||
log.warn("Could not extract server URLs for CalDAV credential validation", {
|
||||
credentialId: credential.id,
|
||||
destinationId: destination.id,
|
||||
credentialServerUrl,
|
||||
destinationServerUrl,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = credentialServerUrl === destinationServerUrl;
|
||||
|
||||
if (!matches) {
|
||||
log.warn("CalDAV credential server URL does not match destination calendar server URL", {
|
||||
credentialId: credential.id,
|
||||
destinationId: destination.id,
|
||||
credentialServerUrl,
|
||||
destinationServerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a CalendarEvent and creates all necessary integration entries for it.
|
||||
* When a video integration is chosen as the event's location, a video integration
|
||||
@@ -774,18 +853,33 @@ export default class EventManager {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const destinationCalendarCredentials = this.calendarCredentials.filter(
|
||||
(c) => c.type === destination.integration
|
||||
);
|
||||
const destinationCalendarCredentials = this.calendarCredentials.filter((c) => {
|
||||
if (c.type !== destination.integration) return false;
|
||||
|
||||
if (c.type === CALDAV_CALENDAR_TYPE) {
|
||||
return this.credentialMatchesDestination(c, destination);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
// It might not be the first connected calendar as it seems that the order is not guaranteed to be ascending of credentialId.
|
||||
const firstCalendarCredential = destinationCalendarCredentials[0] as
|
||||
| (typeof destinationCalendarCredentials)[number]
|
||||
| undefined;
|
||||
|
||||
if (!firstCalendarCredential) {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
if (destination.integration === CALDAV_CALENDAR_TYPE) {
|
||||
log.warn(
|
||||
"No CalDAV credentials found with matching server URL for destination calendar. This prevents credential leakage.",
|
||||
safeStringify({
|
||||
destination: getPiiFreeDestinationCalendar(destination),
|
||||
})
|
||||
);
|
||||
} else {
|
||||
log.warn(
|
||||
"No other credentials found of the same type as the destination calendar. Falling back to first connected calendar"
|
||||
);
|
||||
}
|
||||
await fallbackToFirstCalendarInTheList();
|
||||
} else {
|
||||
log.warn(
|
||||
|
||||
@@ -23,4 +23,5 @@ export enum ErrorCode {
|
||||
BookerLimitExceededReschedule = "booker_limit_exceeded_error_reschedule",
|
||||
BookingNotAllowedByRestrictionSchedule = "booking_not_allowed_by_restriction_schedule_error",
|
||||
RestrictionScheduleNotFound = "restriction_schedule_not_found_error",
|
||||
EventTypeNoHosts = "event_type_no_hosts",
|
||||
}
|
||||
|
||||
@@ -7,7 +7,17 @@ import logger from "@calcom/lib/logger";
|
||||
import type { EventBusyDate } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
|
||||
|
||||
import getCalendarsEvents, { getCalendarsEventsWithTimezones } from "./getCalendarsEvents";
|
||||
import { symmetricDecrypt } from "./crypto";
|
||||
import getCalendarsEvents, {
|
||||
getCalendarsEventsWithTimezones,
|
||||
filterSelectedCalendarsForCredential,
|
||||
} from "./getCalendarsEvents";
|
||||
|
||||
vi.mock("./crypto", () => ({
|
||||
symmetricDecrypt: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
|
||||
|
||||
function buildDelegationCredential(credential: CredentialPayload): CredentialForCalendarService {
|
||||
return {
|
||||
@@ -49,6 +59,12 @@ function buildSelectedCalendar(credential: {
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
error: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
unwatchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
...credential,
|
||||
};
|
||||
}
|
||||
@@ -73,6 +89,8 @@ describe("getCalendarsEvents", () => {
|
||||
teamId: null,
|
||||
appId: "exampleApp",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
user: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -324,6 +342,7 @@ describe("getCalendarsEventsWithTimezones", () => {
|
||||
user: null,
|
||||
appId: "exampleApp",
|
||||
invalid: false,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -471,3 +490,214 @@ describe("getCalendarsEventsWithTimezones", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// CalDAV Credential Leak Prevention Tests
|
||||
describe("CalDAV credential leak prevention", () => {
|
||||
function buildCalDAVCredential(data: {
|
||||
id: number;
|
||||
key: string;
|
||||
userId?: number;
|
||||
}): CredentialForCalendarService {
|
||||
return {
|
||||
id: data.id,
|
||||
type: "caldav_calendar",
|
||||
key: data.key,
|
||||
userId: data.userId || 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "caldav-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCalDAVSelectedCalendar(data: {
|
||||
id: string;
|
||||
externalId: string;
|
||||
credentialId?: number;
|
||||
}): SelectedCalendar {
|
||||
return {
|
||||
id: data.id,
|
||||
userId: 1,
|
||||
integration: "caldav_calendar",
|
||||
externalId: data.externalId,
|
||||
credentialId: data.credentialId || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
googleChannelId: null,
|
||||
googleChannelKind: null,
|
||||
googleChannelResourceId: null,
|
||||
googleChannelResourceUri: null,
|
||||
googleChannelExpiration: null,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
error: null,
|
||||
lastErrorAt: null,
|
||||
watchAttempts: 0,
|
||||
unwatchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
eventTypeId: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("filterSelectedCalendarsForCredential", () => {
|
||||
it("prevents CalDAV credential leak by matching server URLs", () => {
|
||||
// Setup: Two CalDAV servers with different URLs
|
||||
const serverACredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_server_a_key",
|
||||
});
|
||||
|
||||
const serverBCredential = buildCalDAVCredential({
|
||||
id: 2,
|
||||
key: "encrypted_server_b_key",
|
||||
});
|
||||
|
||||
// Mock encrypted credential data for different servers
|
||||
mockedSymmetricDecrypt
|
||||
.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
username: "user_a",
|
||||
password: "pass_a",
|
||||
url: "https://server-a.example.com/dav/calendars/user/",
|
||||
})
|
||||
)
|
||||
.mockReturnValueOnce(
|
||||
JSON.stringify({
|
||||
username: "user_b",
|
||||
password: "pass_b",
|
||||
url: "https://server-b.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
// Selected calendars from both servers
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
credentialId: 1,
|
||||
}),
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
|
||||
credentialId: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
// Test Server A credential - should only return Server A calendars
|
||||
const serverACalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
|
||||
expect(serverACalendars).toHaveLength(1);
|
||||
expect(serverACalendars[0].externalId).toBe(
|
||||
"https://server-a.example.com/dav/calendars/user/calendar1/"
|
||||
);
|
||||
|
||||
// Test Server B credential - should only return Server B calendars
|
||||
const serverBCalendars = filterSelectedCalendarsForCredential(selectedCalendars, serverBCredential);
|
||||
expect(serverBCalendars).toHaveLength(1);
|
||||
expect(serverBCalendars[0].externalId).toBe(
|
||||
"https://server-b.example.com/dav/calendars/user/calendar2/"
|
||||
);
|
||||
});
|
||||
|
||||
it("demonstrates the credential leak that existed before the fix", () => {
|
||||
// This test shows what WOULD happen with naive filtering (integration type only)
|
||||
const serverACredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_server_a_key",
|
||||
});
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
credentialId: 1,
|
||||
}),
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "https://server-b.example.com/dav/calendars/user/calendar2/",
|
||||
credentialId: 2,
|
||||
}),
|
||||
];
|
||||
|
||||
// Legacy filtering (type-only) would return ALL CalDAV calendars
|
||||
const legacyFiltering = selectedCalendars.filter((sc) => sc.integration === "caldav_calendar");
|
||||
expect(legacyFiltering).toHaveLength(2); // This demonstrates the leak - both calendars returned
|
||||
|
||||
// Our new filtering prevents this
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user_a",
|
||||
password: "pass_a",
|
||||
url: "https://server-a.example.com/dav/calendars/user/",
|
||||
})
|
||||
);
|
||||
|
||||
const secureFiltering = filterSelectedCalendarsForCredential(selectedCalendars, serverACredential);
|
||||
expect(secureFiltering).toHaveLength(1); // Only calendars from matching server
|
||||
expect(secureFiltering[0].externalId).toContain("server-a.example.com");
|
||||
});
|
||||
|
||||
it("handles non-CalDAV calendars normally", () => {
|
||||
const googleCredential: CredentialForCalendarService = {
|
||||
id: 1,
|
||||
type: "google_calendar",
|
||||
key: "google_key",
|
||||
userId: 1,
|
||||
user: { email: "test@example.com" },
|
||||
teamId: null,
|
||||
appId: "google-calendar",
|
||||
invalid: false,
|
||||
delegatedTo: null,
|
||||
delegationCredentialId: null,
|
||||
};
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
}),
|
||||
{
|
||||
...buildCalDAVSelectedCalendar({
|
||||
id: "cal_2",
|
||||
externalId: "primary",
|
||||
}),
|
||||
integration: "google_calendar",
|
||||
},
|
||||
];
|
||||
|
||||
const googleCalendars = filterSelectedCalendarsForCredential(selectedCalendars, googleCredential);
|
||||
expect(googleCalendars).toHaveLength(1);
|
||||
expect(googleCalendars[0].integration).toBe("google_calendar");
|
||||
});
|
||||
|
||||
it("handles invalid CalDAV credential URLs gracefully", () => {
|
||||
const invalidCredential = buildCalDAVCredential({
|
||||
id: 1,
|
||||
key: "encrypted_invalid_key",
|
||||
});
|
||||
|
||||
mockedSymmetricDecrypt.mockReturnValue(
|
||||
JSON.stringify({
|
||||
username: "user",
|
||||
password: "pass",
|
||||
url: "invalid-url-format",
|
||||
})
|
||||
);
|
||||
|
||||
const selectedCalendars = [
|
||||
buildCalDAVSelectedCalendar({
|
||||
id: "cal_1",
|
||||
externalId: "https://server-a.example.com/dav/calendars/user/calendar1/",
|
||||
}),
|
||||
];
|
||||
|
||||
const result = filterSelectedCalendarsForCredential(selectedCalendars, invalidCredential);
|
||||
expect(result).toHaveLength(0); // Should return empty array for safety
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,11 @@ import { performance } from "@calcom/lib/server/perfObserver";
|
||||
import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
|
||||
import type { CredentialForCalendarService } from "@calcom/types/Credential";
|
||||
|
||||
import { symmetricDecrypt } from "./crypto";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
|
||||
|
||||
const CALENDSO_ENCRYPTION_KEY = process.env.CALENDSO_ENCRYPTION_KEY || "";
|
||||
// only for Google Calendar for now
|
||||
export const getCalendarsEventsWithTimezones = async (
|
||||
withCredentials: CredentialForCalendarService[],
|
||||
@@ -35,15 +39,16 @@ export const getCalendarsEventsWithTimezones = async (
|
||||
if (!c) return [];
|
||||
/** We rely on the index so we can match credentials with calendars */
|
||||
const { type } = calendarCredentials[i];
|
||||
const credential = calendarToCredentialMap.get(c);
|
||||
/** We just pass the calendars that matched the credential type,
|
||||
* TODO: Migrate credential type or appId
|
||||
*/
|
||||
const passedSelectedCalendars = selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
|
||||
const credential = calendarToCredentialMap.get(c);
|
||||
const passedSelectedCalendars = credential
|
||||
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
|
||||
: selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
|
||||
// We want to fallback to primary calendar when no selectedCalendars are passed
|
||||
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
|
||||
@@ -103,15 +108,17 @@ const getCalendarsEvents = async (
|
||||
if (!calendarService) return [];
|
||||
/** We rely on the index so we can match credentials with calendars */
|
||||
const { type, appId } = calendarCredentials[i];
|
||||
const credential = calendarToCredentialMap.get(calendarService);
|
||||
/** We just pass the calendars that matched the credential type,
|
||||
* TODO: Migrate credential type or appId
|
||||
*/
|
||||
// Important to have them unique so that
|
||||
const passedSelectedCalendars = selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const credential = calendarToCredentialMap.get(calendarService);
|
||||
const passedSelectedCalendars = credential
|
||||
? filterSelectedCalendarsForCredential(selectedCalendars, credential)
|
||||
: selectedCalendars
|
||||
.filter((sc) => sc.integration === type)
|
||||
// Needed to ensure cache keys are consistent
|
||||
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
|
||||
const isADelegationCredential = credential && isDelegationCredential({ credentialId: credential.id });
|
||||
// We want to fallback to primary calendar when no selectedCalendars are passed
|
||||
// Default behaviour for Google Calendar is to use all available calendars, which isn't good default.
|
||||
@@ -179,3 +186,90 @@ const getCalendarsEvents = async (
|
||||
};
|
||||
|
||||
export default getCalendarsEvents;
|
||||
|
||||
/**
|
||||
* Extract server URL from CalDAV calendar externalId
|
||||
*/
|
||||
function getServerUrlFromCalendarExternalId(externalId: string): string | null {
|
||||
try {
|
||||
const url = new URL(externalId);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract server URL from CalDAV credential
|
||||
*/
|
||||
function getServerUrlFromCredential(credential: CredentialForCalendarService): string | null {
|
||||
try {
|
||||
if (credential.type !== "caldav_calendar") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptedData = JSON.parse(symmetricDecrypt(credential.key as string, CALENDSO_ENCRYPTION_KEY));
|
||||
|
||||
if (!decryptedData.url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const url = new URL(decryptedData.url);
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter selected calendars for the specific credential, handling CalDAV server URL matching
|
||||
*/
|
||||
export function filterSelectedCalendarsForCredential(
|
||||
selectedCalendars: SelectedCalendar[],
|
||||
credential: CredentialForCalendarService
|
||||
): SelectedCalendar[] {
|
||||
const { type } = credential;
|
||||
|
||||
// For all other calendar types, use the existing logic
|
||||
if (type !== "caldav_calendar") {
|
||||
return selectedCalendars.filter((sc) => sc.integration === type);
|
||||
}
|
||||
|
||||
const credentialServerUrl = getServerUrlFromCredential(credential);
|
||||
|
||||
if (!credentialServerUrl) {
|
||||
log.warn("Could not extract server URL from CalDAV credential", {
|
||||
credentialId: credential.id,
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectedCalendars.filter((sc) => {
|
||||
if (sc.integration !== type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const calendarServerUrl = getServerUrlFromCalendarExternalId(sc.externalId);
|
||||
|
||||
if (!calendarServerUrl) {
|
||||
log.warn("Could not extract server URL from calendar externalId", {
|
||||
externalId: sc.externalId,
|
||||
integration: sc.integration,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const matches = credentialServerUrl === calendarServerUrl;
|
||||
|
||||
if (!matches) {
|
||||
log.debug("CalDAV calendar server URL does not match credential server URL", {
|
||||
credentialId: credential.id,
|
||||
credentialServerUrl,
|
||||
calendarServerUrl,
|
||||
calendarExternalId: sc.externalId,
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { DestinationCalendar, SelectedCalendar, User } from "@calcom/prisma
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import { DestinationCalendarRepository } from "./server/repository/destinationCalendar";
|
||||
import { EventTypeRepository } from "./server/repository/eventType";
|
||||
import { SelectedCalendarRepository } from "./server/repository/selectedCalendar";
|
||||
|
||||
@@ -134,20 +135,18 @@ async function handleNoDestinationCalendar({
|
||||
}
|
||||
}
|
||||
|
||||
user.destinationCalendar = await prisma.destinationCalendar.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
integration,
|
||||
externalId,
|
||||
primaryEmail,
|
||||
...(!isDelegationCredential({ credentialId })
|
||||
? {
|
||||
credentialId,
|
||||
}
|
||||
: {
|
||||
delegationCredentialId,
|
||||
}),
|
||||
},
|
||||
user.destinationCalendar = await DestinationCalendarRepository.createIfNotExistsForUser({
|
||||
userId: user.id,
|
||||
integration,
|
||||
externalId,
|
||||
primaryEmail,
|
||||
...(!isDelegationCredential({ credentialId })
|
||||
? {
|
||||
credentialId,
|
||||
}
|
||||
: {
|
||||
delegationCredentialId,
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomain
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
@@ -179,6 +180,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
log.error("Error handling the response", safeStringify(e));
|
||||
throw new Error("Error handling the response");
|
||||
}
|
||||
|
||||
// TODO: To be done using sentry tracing
|
||||
|
||||
@@ -111,6 +111,7 @@ function getStatusCode(cause: Error | ErrorWithCode): number {
|
||||
case ErrorCode.BookingNotAllowedByRestrictionSchedule:
|
||||
case ErrorCode.BookerLimitExceeded:
|
||||
case ErrorCode.BookerLimitExceededReschedule:
|
||||
case ErrorCode.EventTypeNoHosts:
|
||||
return 400;
|
||||
// 409 Conflict
|
||||
case ErrorCode.NoAvailableUsersFound:
|
||||
|
||||
@@ -14,6 +14,18 @@ export class DestinationCalendarRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async createIfNotExistsForUser(
|
||||
data: { userId: number } & Prisma.DestinationCalendarUncheckedCreateInput
|
||||
) {
|
||||
const conflictingCalendar = await DestinationCalendarRepository.findConflictingForUser(data);
|
||||
if (conflictingCalendar) {
|
||||
return conflictingCalendar;
|
||||
}
|
||||
return await prisma.destinationCalendar.create({
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
static async getByUserId(userId: number) {
|
||||
return await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
@@ -36,6 +48,21 @@ export class DestinationCalendarRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private static async findConflictingForUser(data: {
|
||||
userId: number;
|
||||
integration: string;
|
||||
externalId: string;
|
||||
}) {
|
||||
return await DestinationCalendarRepository.find({
|
||||
where: {
|
||||
userId: data.userId,
|
||||
integration: data.integration,
|
||||
externalId: data.externalId,
|
||||
eventTypeId: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async upsert({
|
||||
where,
|
||||
update,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -6,6 +7,8 @@ import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsBookingService } from "../../service/insightsBooking";
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
teamRole = MembershipRole.MEMBER,
|
||||
@@ -204,7 +207,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
expect(conditions).toEqual(NOTHING_CONDITION);
|
||||
});
|
||||
|
||||
it("should return NOTHING for non-owner/admin user", async () => {
|
||||
@@ -239,7 +242,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
expect(conditions).toEqual(NOTHING_CONDITION);
|
||||
|
||||
// Clean up
|
||||
await prisma.membership.delete({
|
||||
@@ -267,14 +270,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(Prisma.sql`("userId" = ${testData.user.id}) AND ("teamId" IS NULL)`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -296,24 +292,11 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: testData.team.id,
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: [testData.user.id],
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`(("teamId" = ${testData.team.id}) AND ("isTeamBooking" = true)) OR (("userId" = ANY(${[
|
||||
testData.user.id,
|
||||
]})) AND ("isTeamBooking" = false))`
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
@@ -346,26 +329,18 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: {
|
||||
in: [testData.org.id, testData.team.id, team2.id, team3.id],
|
||||
},
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: [testData.user.id, user2.id, user3.id],
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`(("teamId" = ANY(${[
|
||||
testData.org.id,
|
||||
testData.team.id,
|
||||
team2.id,
|
||||
team3.id,
|
||||
]})) AND ("isTeamBooking" = true)) OR (("userId" = ANY(${[
|
||||
testData.user.id,
|
||||
user2.id,
|
||||
user3.id,
|
||||
]})) AND ("isTeamBooking" = false))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -406,13 +381,9 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -433,13 +404,7 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(Prisma.sql`"userId" = ${testData.user.id}`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
@@ -461,90 +426,15 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
{
|
||||
userId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`(("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})) AND ("userId" = ${testData.user.id})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache authorization conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should cache filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
describe("getBaseConditions", () => {
|
||||
it("should combine authorization and filter conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
@@ -587,16 +477,18 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
const baseConditions = await service.getBaseConditions();
|
||||
const results = await prisma.$queryRaw<{ id: number }[]>`
|
||||
SELECT id FROM "BookingTimeStatusDenormalized"
|
||||
WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
// Should return the user booking since it matches both conditions
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(userBooking.id);
|
||||
expect(results).toEqual([
|
||||
{
|
||||
id: userBooking.id,
|
||||
},
|
||||
]);
|
||||
|
||||
// Clean up
|
||||
await prisma.booking.delete({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
@@ -26,6 +26,13 @@ export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope",
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsBookingServicePublicOptions = {
|
||||
scope: "user" | "org" | "team";
|
||||
userId: number;
|
||||
orgId: number;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsBookingServiceFilterOptions = {
|
||||
@@ -33,16 +40,14 @@ export type InsightsBookingServiceFilterOptions = {
|
||||
memberUserId?: number;
|
||||
};
|
||||
|
||||
const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
export class InsightsBookingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsBookingServiceOptions | null;
|
||||
private filters?: InsightsBookingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
|
||||
private cachedAuthConditions?: Prisma.Sql;
|
||||
private cachedFilterConditions?: Prisma.Sql | null;
|
||||
|
||||
constructor({
|
||||
prisma,
|
||||
@@ -50,7 +55,7 @@ export class InsightsBookingService {
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsBookingServiceOptions;
|
||||
options: InsightsBookingServicePublicOptions;
|
||||
filters?: InsightsBookingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
@@ -60,89 +65,86 @@ export class InsightsBookingService {
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async findMany(findManyArgs: Prisma.BookingTimeStatusDenormalizedFindManyArgs) {
|
||||
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
return this.prisma.bookingTimeStatusDenormalized.findMany({
|
||||
...findManyArgs,
|
||||
where: {
|
||||
...findManyArgs.where,
|
||||
AND: [authConditions, filterConditions].filter(
|
||||
(c): c is Prisma.BookingTimeStatusDenormalizedWhereInput => c !== null
|
||||
),
|
||||
},
|
||||
});
|
||||
if (authConditions && filterConditions) {
|
||||
return Prisma.sql`(${authConditions}) AND (${filterConditions})`;
|
||||
} else if (authConditions) {
|
||||
return authConditions;
|
||||
} else if (filterConditions) {
|
||||
return filterConditions;
|
||||
} else {
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
async getAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (this.cachedAuthConditions === undefined) {
|
||||
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
||||
}
|
||||
return this.cachedAuthConditions;
|
||||
}
|
||||
|
||||
async getFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
|
||||
async getFilterConditions(): Promise<Prisma.Sql | null> {
|
||||
if (this.cachedFilterConditions === undefined) {
|
||||
this.cachedFilterConditions = await this.buildFilterConditions();
|
||||
}
|
||||
return this.cachedFilterConditions;
|
||||
}
|
||||
|
||||
async buildFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
|
||||
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
|
||||
async buildFilterConditions(): Promise<Prisma.Sql | null> {
|
||||
const conditions: Prisma.Sql[] = [];
|
||||
|
||||
if (!this.filters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.filters.eventTypeId) {
|
||||
conditions.push({
|
||||
OR: [{ eventTypeId: this.filters.eventTypeId }, { eventParentId: this.filters.eventTypeId }],
|
||||
});
|
||||
conditions.push(
|
||||
Prisma.sql`("eventTypeId" = ${this.filters.eventTypeId}) OR ("eventParentId" = ${this.filters.eventTypeId})`
|
||||
);
|
||||
}
|
||||
|
||||
if (this.filters.memberUserId) {
|
||||
conditions.push({
|
||||
userId: this.filters.memberUserId,
|
||||
});
|
||||
conditions.push(Prisma.sql`"userId" = ${this.filters.memberUserId}`);
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : null;
|
||||
if (conditions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Join all conditions with AND
|
||||
return conditions.reduce((acc, condition, index) => {
|
||||
if (index === 0) return condition;
|
||||
return Prisma.sql`(${acc}) AND (${condition})`;
|
||||
});
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
async buildAuthorizationConditions(): Promise<Prisma.Sql> {
|
||||
if (!this.options) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
||||
if (!isOwnerOrAdmin) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
|
||||
|
||||
if (this.options.scope === "user") {
|
||||
conditions.push({
|
||||
userId: this.options.userId,
|
||||
teamId: null,
|
||||
});
|
||||
return Prisma.sql`("userId" = ${this.options.userId}) AND ("teamId" IS NULL)`;
|
||||
} else if (this.options.scope === "org") {
|
||||
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
|
||||
return await this.buildOrgAuthorizationCondition(this.options);
|
||||
} else if (this.options.scope === "team") {
|
||||
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
|
||||
return await this.buildTeamAuthorizationCondition(this.options);
|
||||
} else {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
return {
|
||||
AND: conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
): Promise<Prisma.Sql> {
|
||||
// Get all teams from the organization
|
||||
const teamRepo = new TeamRepository(this.prisma);
|
||||
const teamsFromOrg = await teamRepo.findAllByParentId({
|
||||
@@ -159,31 +161,22 @@ export class InsightsBookingService {
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
teamId: {
|
||||
in: teamIds,
|
||||
},
|
||||
isTeamBooking: true,
|
||||
},
|
||||
...(userIdsFromOrg.length > 0
|
||||
? [
|
||||
{
|
||||
userId: {
|
||||
in: Array.from(new Set(userIdsFromOrg)),
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
const conditions: Prisma.Sql[] = [Prisma.sql`("teamId" = ANY(${teamIds})) AND ("isTeamBooking" = true)`];
|
||||
|
||||
if (userIdsFromOrg.length > 0) {
|
||||
const uniqueUserIds = Array.from(new Set(userIdsFromOrg));
|
||||
conditions.push(Prisma.sql`("userId" = ANY(${uniqueUserIds})) AND ("isTeamBooking" = false)`);
|
||||
}
|
||||
|
||||
return conditions.reduce((acc, condition, index) => {
|
||||
if (index === 0) return condition;
|
||||
return Prisma.sql`(${acc}) OR (${condition})`;
|
||||
});
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
): Promise<Prisma.Sql> {
|
||||
const teamRepo = new TeamRepository(this.prisma);
|
||||
const childTeamOfOrg = await teamRepo.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
@@ -191,7 +184,7 @@ export class InsightsBookingService {
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
return NOTHING_CONDITION;
|
||||
}
|
||||
|
||||
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
|
||||
@@ -200,20 +193,18 @@ export class InsightsBookingService {
|
||||
});
|
||||
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
teamId: options.teamId,
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeam,
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const conditions: Prisma.Sql[] = [
|
||||
Prisma.sql`("teamId" = ${options.teamId}) AND ("isTeamBooking" = true)`,
|
||||
];
|
||||
|
||||
if (userIdsFromTeam.length > 0) {
|
||||
conditions.push(Prisma.sql`("userId" = ANY(${userIdsFromTeam})) AND ("isTeamBooking" = false)`);
|
||||
}
|
||||
|
||||
return conditions.reduce((acc, condition, index) => {
|
||||
if (index === 0) return condition;
|
||||
return Prisma.sql`(${acc}) OR (${condition})`;
|
||||
});
|
||||
}
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
@@ -223,7 +214,7 @@ export class InsightsBookingService {
|
||||
membership &&
|
||||
membership.accepted &&
|
||||
membership.role &&
|
||||
([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role)
|
||||
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+31
-24
@@ -50,7 +50,7 @@ export class NameDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&name=bob`,\
|
||||
the name field will be prefilled with this value and disabled.",
|
||||
the name field will be prefilled with this value and disabled. In case of Booker atom need to pass 'name' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{name: 'bob'}}`. See guide https://cal.com/docs/platform/guides/booking-fields",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export class SplitNameDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&firstName=bob&lastName=jones`,\
|
||||
the first name and last name fields will be prefilled with this value and disabled. In case of Booker atom need to pass firstName and lastName to defaultFormValues prop or pass name prop but as a string containing name and surname.",
|
||||
the first name and last name fields will be prefilled with this value and disabled. In case of Booker atom need to pass 'firstName' and 'lastName' to defaultFormValues prop e.g. `defaultFormValues={{firstName: 'bob', lastName: 'doe'}}` or pass 'name' prop but as a string containing name and surname e.g. `defaultFormValues={{name: 'bob doe'}}`. See guide https://cal.com/docs/platform/guides/booking-fields",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export class EmailDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&email=bob@gmail.com`,\
|
||||
the email field will be prefilled with this value and disabled.",
|
||||
the email field will be prefilled with this value and disabled. In case of Booker atom need to pass 'email' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{email: 'bob@gmail.com'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -184,7 +184,7 @@ export class TitleDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&title=journey`,\
|
||||
the title field will be prefilled with this value and disabled.",
|
||||
the title field will be prefilled with this value and disabled. In case of Booker atom need to pass 'title' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{title: 'very important meeting'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export class NotesDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `¬es=journey`,\
|
||||
the notes field will be prefilled with this value and disabled.",
|
||||
the notes field will be prefilled with this value and disabled. In case of Booker atom need to pass 'notes' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{notes: 'bring notebook and paper'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -279,7 +279,7 @@ export class GuestsDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&guests=bob@cal.com`,\
|
||||
the guests field will be prefilled with this value and disabled.",
|
||||
the guests field will be prefilled with this value and disabled. In case of Booker atom need to pass 'guests' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{guests: ['bob@gmail.com', 'alice@gmail.com']}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -322,7 +322,7 @@ export class RescheduleReasonDefaultFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if URL contains query parameter `&rescheduleReason=travel`,\
|
||||
the rescheduleReason field will be prefilled with this value and disabled.",
|
||||
the rescheduleReason field will be prefilled with this value and disabled. In case of Booker atom need to pass 'rescheduleReason' to defaultFormValues prop with the desired value e.g. `defaultFormValues={{rescheduleReason: 'bob'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
}
|
||||
@@ -360,7 +360,7 @@ export class PhoneFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `phone` and the URL contains query parameter `&phone=1234567890`,\
|
||||
the phone field will be prefilled with this value and disabled.",
|
||||
the phone field will be prefilled with this value and disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{phone: '+37122222222'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -407,7 +407,7 @@ export class AddressFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `address` and the URL contains query parameter `&address=1234 Main St, London`,\
|
||||
the address field will be prefilled with this value and disabled.",
|
||||
the address field will be prefilled with this value and disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{address: 'mainstreat 10, new york'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -453,8 +453,8 @@ export class TextFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `who-referred-you` and the URL contains query parameter `&who-referred-you=bob`,\
|
||||
the text field will be prefilled with this value and disabled.",
|
||||
For example, if the slug is `friend` and the URL contains query parameter `&friend=bob`,\
|
||||
the text field will be prefilled with this value and disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{friend: 'bob'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -500,7 +500,8 @@ export class UrlFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `video-url` and the URL contains query parameter `&video-url=https://youtube.com/abc`the url field will be prefilled with this value and disabled.",
|
||||
For example, if the slug is `videourl` and the URL contains query parameter `&videourl=https://youtube.com/abc`the url field will be prefilled with this value and disabled. \
|
||||
In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{videourl: 'https://caltube.com/123'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -546,8 +547,8 @@ export class NumberFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `calories-per-day` and the URL contains query parameter `&calories-per-day=3000`,\
|
||||
the number field will be prefilled with this value and disabled.",
|
||||
For example, if the slug is `calories` and the URL contains query parameter `&calories=3000`,\
|
||||
the number field will be prefilled with this value and disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{calories: 3000}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -593,8 +594,8 @@ export class TextAreaFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `dear-diary` and the URL contains query parameter `&dear-diary=Today I shipped a feature`,\
|
||||
the text area will be prefilled with this value and disabled.",
|
||||
For example, if the slug is `reflection` and the URL contains query parameter `&reflection=Today I shipped a feature`,\
|
||||
the text area will be prefilled with this value and disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{reflection: 'Today i shipped a feature'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -645,7 +646,7 @@ export class SelectFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`,\
|
||||
the 'italian' will be selected and the select field will be disabled.",
|
||||
the 'italian' will be selected and the select field will be disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{language: 'italian'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -689,8 +690,8 @@ export class MultiSelectFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `consultants` and the URL contains query parameter `&consultants=en&language=it`,\
|
||||
the 'en' and 'it' will be selected and the select field will be disabled.",
|
||||
For example, if the slug is `language` and the URL contains query parameter `&language=en&language=it`,\
|
||||
the 'en' and 'it' will be selected and the select field will be disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{language: ['en', 'it']}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -737,7 +738,7 @@ export class MultiEmailFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `consultants` and the URL contains query parameter `&consultants=alice@gmail.com&consultants=bob@gmail.com`,\
|
||||
the these emails will be added and none more can be added.",
|
||||
the these emails will be added and none more can be added. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{consultants: ['alice@gmail.com', 'bob@gmail.com']}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -781,8 +782,8 @@ export class CheckboxGroupFieldInput_2024_06_14 {
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `notify-me` and the URL contains query parameter `¬ify-me=true`,\
|
||||
the checkbox will be selected and the checkbox field will be disabled.",
|
||||
For example, if the slug is `notify` and the URL contains query parameter `¬ify=true`,\
|
||||
the checkbox will be selected and the checkbox field will be disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{notify: true}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -827,7 +828,7 @@ export class RadioGroupFieldInput_2024_06_14 {
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `language` and options of this select field are ['english', 'italian'] and the URL contains query parameter `&language=italian`,\
|
||||
the 'italian' radio button will be selected and the select field will be disabled.",
|
||||
the 'italian' radio button will be selected and the select field will be disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{language: 'italian'}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@@ -863,7 +864,13 @@ export class BooleanFieldInput_2024_06_14 {
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@DocsPropertyOptional({ type: Boolean })
|
||||
@DocsPropertyOptional({
|
||||
type: Boolean,
|
||||
description:
|
||||
"Disable this booking field if the URL contains query parameter with key equal to the slug and prefill it with the provided value.\
|
||||
For example, if the slug is `notify` and the URL contains query parameter `¬ify=true`,\
|
||||
the checkbox will be selected and the checkbox field will be disabled. In case of Booker atom need to pass slug you used for this booking field to defaultFormValues prop with the desired value e.g. `defaultFormValues={{notify: true}}`. See guide https://cal.com/docs/platform/guides/booking-field",
|
||||
})
|
||||
disableOnPrefill?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
|
||||
Binary file not shown.
+42
-31
@@ -18,17 +18,18 @@ let eventType4: EventType;
|
||||
describe("listWithTeamHandler", () => {
|
||||
beforeAll(async () => {
|
||||
// Create users, teams and event types
|
||||
const timestamp = Date.now();
|
||||
user1 = await prisma.user.create({
|
||||
data: {
|
||||
username: "testuser-lwt-1",
|
||||
email: "testuser-lwt-1@example.com",
|
||||
username: `testuser-lwt-1-${timestamp}`,
|
||||
email: `testuser-lwt-1-${timestamp}@example.com`,
|
||||
name: "Test User 1",
|
||||
},
|
||||
});
|
||||
user2 = await prisma.user.create({
|
||||
data: {
|
||||
username: "testuser-lwt-2",
|
||||
email: "testuser-lwt-2@example.com",
|
||||
username: `testuser-lwt-2-${timestamp}`,
|
||||
email: `testuser-lwt-2-${timestamp}@example.com`,
|
||||
name: "Test User 2",
|
||||
},
|
||||
});
|
||||
@@ -36,7 +37,7 @@ describe("listWithTeamHandler", () => {
|
||||
team1 = await prisma.team.create({
|
||||
data: {
|
||||
name: "Team 1 lwt",
|
||||
slug: "team-1-lwt",
|
||||
slug: `team-1-lwt-${timestamp}`,
|
||||
members: {
|
||||
create: {
|
||||
userId: user1.id,
|
||||
@@ -50,14 +51,14 @@ describe("listWithTeamHandler", () => {
|
||||
team2 = await prisma.team.create({
|
||||
data: {
|
||||
name: "Team 2 lwt",
|
||||
slug: "team-2-lwt",
|
||||
slug: `team-2-lwt-${timestamp}`,
|
||||
},
|
||||
});
|
||||
|
||||
eventType1 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "User 1 Event",
|
||||
slug: "user1-event-lwt",
|
||||
slug: `user1-event-lwt-${timestamp}`,
|
||||
length: 30,
|
||||
userId: user1.id,
|
||||
},
|
||||
@@ -66,7 +67,7 @@ describe("listWithTeamHandler", () => {
|
||||
eventType2 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Team 1 Event",
|
||||
slug: "team1-event-lwt",
|
||||
slug: `team1-event-lwt-${timestamp}`,
|
||||
length: 30,
|
||||
teamId: team1.id,
|
||||
userId: user1.id,
|
||||
@@ -76,7 +77,7 @@ describe("listWithTeamHandler", () => {
|
||||
eventType3 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "User 2 Event",
|
||||
slug: "user2-event-lwt",
|
||||
slug: `user2-event-lwt-${timestamp}`,
|
||||
length: 30,
|
||||
userId: user2.id,
|
||||
},
|
||||
@@ -85,7 +86,7 @@ describe("listWithTeamHandler", () => {
|
||||
eventType4 = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Team 2 Event",
|
||||
slug: "team2-event-lwt",
|
||||
slug: `team2-event-lwt-${timestamp}`,
|
||||
length: 30,
|
||||
teamId: team2.id,
|
||||
userId: user2.id,
|
||||
@@ -94,27 +95,37 @@ describe("listWithTeamHandler", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.eventType.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [eventType1.id, eventType2.id, eventType3.id, eventType4.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.team.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [team1.id, team2.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [user1.id, user2.id],
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
if (eventType1?.id || eventType2?.id || eventType3?.id || eventType4?.id) {
|
||||
await prisma.eventType.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [eventType1?.id, eventType2?.id, eventType3?.id, eventType4?.id].filter(Boolean),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (team1?.id || team2?.id) {
|
||||
await prisma.team.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [team1?.id, team2?.id].filter(Boolean),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (user1?.id || user2?.id) {
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: [user1?.id, user2?.id].filter(Boolean),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Test cleanup failed:", error);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return user's own event types and event types of teams they are a member of", async () => {
|
||||
|
||||
Vendored
+1
@@ -20,6 +20,7 @@ declare module "next-auth" {
|
||||
id: PrismaUser["id"];
|
||||
emailVerified?: PrismaUser["emailVerified"];
|
||||
email_verified?: boolean;
|
||||
completedOnboarding?: boolean;
|
||||
impersonatedBy?: {
|
||||
id: number;
|
||||
role: PrismaUser["role"];
|
||||
|
||||
@@ -74,7 +74,7 @@ export const AppListCard = (props: AppListCardProps & { highlight?: boolean }) =
|
||||
<div className="flex gap-x-2 pt-2">
|
||||
<Icon name="circle-alert" className="h-8 w-8 text-red-500 sm:h-4 sm:w-4" />
|
||||
<ListItemText component="p" className="whitespace-pre-wrap text-red-500">
|
||||
{t("invalid_credential")}
|
||||
{t("invalid_credential", { appName: title })}
|
||||
</ListItemText>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2519,7 +2519,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.257"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
"@calcom/prisma": "*"
|
||||
@@ -3565,13 +3565,13 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.257":
|
||||
version: 0.0.257
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.257"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.258":
|
||||
version: 0.0.258
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.258"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: d45647591a26b6a73b6ae7602d8b8e59d87339de0e49468737437ffdd716fbd953898cd59a1eff8938326eba9ca895c28990bc91355aa55006980a2e716ddfd0
|
||||
checksum: ca522eee904921d4a2f285ba6ed195118a6d68c76fb454d826574d1f5487e8d3d7e41915c968ad0eff6bb01730578ee414069684c7a395b0c19b910f136bffb7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user