Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b81d6f6b | ||
|
|
ac117a14eb | ||
|
|
f85f226982 | ||
|
|
f4b48e863b | ||
|
|
4dc22606d9 | ||
|
|
292073eb4a | ||
|
|
1878802213 | ||
|
|
9cfc74a74f | ||
|
|
c472bce54f | ||
|
|
a498641b61 | ||
|
|
ec58e59865 | ||
|
|
4f95cff70e | ||
|
|
fa66f2531f | ||
|
|
b40d448e07 | ||
|
|
21add18ad8 | ||
|
|
10fca86ddc | ||
|
|
c2fc8774df | ||
|
|
969b58f17d | ||
|
|
a0e25ff270 | ||
|
|
df30f5c703 | ||
|
|
fb59342de4 | ||
|
|
a84e8987cb | ||
|
|
c4eda5f21d | ||
|
|
e6da5c85e0 | ||
|
|
bfc6d6e1e9 | ||
|
|
10bf982028 | ||
|
|
8ee7b12c2b | ||
|
|
afe5fea6eb | ||
|
|
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 |
@@ -0,0 +1,21 @@
|
||||
# Review Instructions
|
||||
|
||||
- Prefer early returns. It is recommended to throw/return early so we can ensure null-checks and prevent further nesting.
|
||||
- Prefer Composition over Prop Drilling. Instead of relying on prop drilling, let's try to take advantage of react children feature.
|
||||
- For Prisma queries:
|
||||
- Only select data you need
|
||||
1) Increased performance overhead: When you select all columns from a table, Prisma retrieves all the data associated with each row, which can result in a significant performance impact. This includes reading unnecessary columns and transferring a large volume of data between your application and the database.
|
||||
2) Unnecessary data exposure: Selecting all columns exposes sensitive data that might not be required by your application. This can pose security risks, especially if the table contains sensitive user information or proprietary data. (In our case app credentials and booking information)
|
||||
- TLDR: Never use Includes - use select
|
||||
- Select selects only the fields you specify explicitly
|
||||
- Include selects the relationship AND all the fields of the table the relationship belongs to
|
||||
- Make sure the credential.key field is never returned back from tRPC endpoints or APIs
|
||||
- Always use t() for text localization in frontend code; direct text embedding should trigger a warning.
|
||||
- Check if there's any O(n^2) logic in backend code; we should aim for O(n log n) or O(n) ideally.
|
||||
- Check if there are circular references introduced. We should never allow these.
|
||||
- Flag excessive Day.js use in performance-critical code. Functions like `.add`, `.diff`, `.isBefore`, and `.isAfter` are slow, especially in timezone mode. Prefer `.utc()` for better performance. Where possible, replace with native Date and direct `.valueOf()` comparisons for faster execution. Recommend using native methods or Day.js `.utc()` consistently in hot paths like loops.
|
||||
- When docs changes are made in /docs/api-reference/v2/openapi.json, ensure the following
|
||||
- "summary" fields are short and concise
|
||||
- "summary" fields do not end with a period
|
||||
- "summary" fields are written in proper American english
|
||||
- When changes to API v2 or v1 are made, ensure there are no breaking changes on existing endpoints. Instead, there needs to be a newly versioned endpoint with the updated functionality but the old one must remain functional
|
||||
@@ -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
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": "*",
|
||||
|
||||
@@ -1581,17 +1581,55 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
|
||||
describe("cancel bookings", () => {
|
||||
afterEach(async () => {
|
||||
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
|
||||
});
|
||||
|
||||
it("should cancel booking", async () => {
|
||||
const createBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Cancel",
|
||||
email: "mr_proper_cancel@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(201);
|
||||
|
||||
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||
expect(responseDataIsBooking(createResponseBody.data)).toBe(true);
|
||||
|
||||
if (!responseDataIsBooking(createResponseBody.data)) {
|
||||
throw new Error("Failed to create booking for test");
|
||||
}
|
||||
|
||||
const testBooking: BookingOutput_2024_08_13 = createResponseBody.data;
|
||||
|
||||
const booking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.status).toEqual("ACCEPTED");
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
||||
expect(booking).toBeDefined();
|
||||
expect(booking?.status).toEqual("ACCEPTED");
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
|
||||
.post(`/v2/bookings/${testBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
@@ -1610,27 +1648,61 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual("cancelled");
|
||||
expect(data.cancellationReason).toEqual(body.cancellationReason);
|
||||
expect(data.start).toEqual(rescheduledBooking.start);
|
||||
expect(data.end).toEqual(rescheduledBooking.end);
|
||||
expect(data.duration).toEqual(rescheduledBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
|
||||
expect(data.location).toEqual(rescheduledBooking.location);
|
||||
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
|
||||
expect(data.start).toEqual(testBooking.start);
|
||||
expect(data.end).toEqual(testBooking.end);
|
||||
expect(data.duration).toEqual(testBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(testBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(testBooking.attendees[0]);
|
||||
expect(data.location).toEqual(testBooking.location);
|
||||
expect(data.absentHost).toEqual(testBooking.absentHost);
|
||||
|
||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(testBooking.uid);
|
||||
expect(cancelledBooking).toBeDefined();
|
||||
expect(cancelledBooking?.status).toEqual("CANCELLED");
|
||||
});
|
||||
});
|
||||
|
||||
it("should cancel recurring booking", async () => {
|
||||
const createBody: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: recurringEventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper Recurring Cancel",
|
||||
email: "mr_proper_recurring_cancel@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(201);
|
||||
|
||||
const createResponseBody: CreateBookingOutput_2024_08_13 = createResponse.body;
|
||||
expect(responseDataIsRecurringBooking(createResponseBody.data)).toBe(true);
|
||||
|
||||
if (!responseDataIsRecurringBooking(createResponseBody.data)) {
|
||||
throw new Error("Failed to create recurring booking for test");
|
||||
}
|
||||
|
||||
const testRecurringBooking: RecurringBookingOutput_2024_08_13[] = createResponseBody.data;
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${createdRecurringBooking[1].recurringBookingUid}/cancel`)
|
||||
.post(`/v2/bookings/${testRecurringBooking[1].recurringBookingUid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
@@ -1643,7 +1715,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
if (responseDataIsRecurringBooking(responseBody.data)) {
|
||||
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
|
||||
expect(data.length).toEqual(4);
|
||||
expect(data.length).toEqual(3);
|
||||
|
||||
const firstBooking = data[0];
|
||||
expect(firstBooking.status).toEqual("cancelled");
|
||||
@@ -1653,9 +1725,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
const thirdBooking = data[2];
|
||||
expect(thirdBooking.status).toEqual("cancelled");
|
||||
|
||||
const fourthBooking = data[3];
|
||||
expect(fourthBooking.status).toEqual("cancelled");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected recurring booking but received non array response"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -95,9 +95,7 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter(
|
||||
(user) => user.isPlatformManaged && user.membership === "MEMBER"
|
||||
);
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
|
||||
eventType.teamMembers = onlyManagedTeamMembers;
|
||||
|
||||
return eventType;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { Or } from "./or.guard";
|
||||
@@ -0,0 +1,104 @@
|
||||
import { ExecutionContext, CanActivate } from "@nestjs/common";
|
||||
|
||||
import { Or } from "./or.guard";
|
||||
|
||||
// Mock guards for testing
|
||||
class MockGuard1 implements CanActivate {
|
||||
constructor(private shouldPass: boolean) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
return this.shouldPass;
|
||||
}
|
||||
}
|
||||
|
||||
class MockGuard2 implements CanActivate {
|
||||
constructor(private shouldPass: boolean) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
return this.shouldPass;
|
||||
}
|
||||
}
|
||||
|
||||
class MockGuard3 implements CanActivate {
|
||||
constructor(private shouldThrow: boolean = false) {}
|
||||
|
||||
async canActivate(): Promise<boolean> {
|
||||
if (this.shouldThrow) {
|
||||
throw new Error("Guard failed");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
describe("OrGuard", () => {
|
||||
let guard: InstanceType<ReturnType<typeof Or>>;
|
||||
let mockExecutionContext: ExecutionContext;
|
||||
let mockModuleRef: any;
|
||||
|
||||
beforeEach(() => {
|
||||
mockModuleRef = {
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||
guard = new OrGuardClass(mockModuleRef);
|
||||
mockExecutionContext = {} as ExecutionContext;
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(guard).toBeDefined();
|
||||
});
|
||||
|
||||
it("should grant access when first guard passes", async () => {
|
||||
const mockGuard1 = new MockGuard1(true);
|
||||
const mockGuard2 = new MockGuard2(false);
|
||||
|
||||
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should grant access when second guard passes", async () => {
|
||||
const mockGuard1 = new MockGuard1(false);
|
||||
const mockGuard2 = new MockGuard2(true);
|
||||
|
||||
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should deny access when all guards fail", async () => {
|
||||
const mockGuard1 = new MockGuard1(false);
|
||||
const mockGuard2 = new MockGuard2(false);
|
||||
|
||||
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should continue checking other guards when one throws an error", async () => {
|
||||
const mockGuard1 = new MockGuard3(true); // throws error
|
||||
const mockGuard2 = new MockGuard2(true); // passes
|
||||
|
||||
mockModuleRef.get.mockReturnValueOnce(mockGuard1).mockReturnValueOnce(mockGuard2);
|
||||
|
||||
const result = await guard.canActivate(mockExecutionContext);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Or decorator", () => {
|
||||
it("should create a guard class with the specified guards", () => {
|
||||
const OrGuardClass = Or([MockGuard1, MockGuard2]);
|
||||
|
||||
expect(OrGuardClass).toBeDefined();
|
||||
expect(typeof OrGuardClass).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, Type, Logger } from "@nestjs/common";
|
||||
import { ModuleRef } from "@nestjs/core";
|
||||
|
||||
/**
|
||||
* Decorator function that creates an Or guard with the specified guards
|
||||
* @param guards Array of guard classes to evaluate with OR logic
|
||||
* @returns A guard class that grants access if ANY of the provided guards return true
|
||||
*/
|
||||
export function Or(guards: Type<CanActivate>[]) {
|
||||
@Injectable()
|
||||
class OrGuard implements CanActivate {
|
||||
public readonly logger = new Logger("OrGuard");
|
||||
|
||||
constructor(public readonly moduleRef: ModuleRef) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let lastError: unknown | null = null;
|
||||
for (const Guard of guards) {
|
||||
try {
|
||||
const guardInstance = this.moduleRef.get(Guard, { strict: false });
|
||||
const result = await Promise.resolve(guardInstance.canActivate(context));
|
||||
|
||||
if (result === true) {
|
||||
this.logger.log(`OrGuard - Guard ${Guard.name} granted access`);
|
||||
return true; // Access granted if any guard returns true
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
// If a guard throws an exception, it implies failure for that specific guard.
|
||||
// We catch it and continue checking other guards in the OR chain.
|
||||
// If an exception should stop the entire chain immediately, re-throw it here.
|
||||
this.logger.log(
|
||||
`OrGuard - Guard ${Guard.name} failed: ${
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log("OrGuard - All guards failed, access denied");
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return OrGuard;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class IsUserRoutingForm implements CanActivate {
|
||||
constructor(private readonly dbRead: PrismaReadService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const routingFormId: string = request.params.routingFormId;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
if (!routingFormId) {
|
||||
throw new ForbiddenException("IsUserRoutingForm - No routing form id found in request params.");
|
||||
}
|
||||
|
||||
const userRoutingForm = await this.dbRead.prisma.app_RoutingForms_Form.findFirst({
|
||||
where: {
|
||||
id: routingFormId,
|
||||
userId: Number(user.id),
|
||||
teamId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!userRoutingForm) {
|
||||
throw new ForbiddenException(
|
||||
`Routing Form with id=${routingFormId} is not a user Routing Form owned by user with id=${user.id}.`
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+35
-4
@@ -63,6 +63,7 @@ describe("Assign all team members", () => {
|
||||
let firstManagedUser: CreateManagedUserData;
|
||||
let secondManagedUser: CreateManagedUserData;
|
||||
|
||||
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
|
||||
let roundRobinEventType: TeamEventTypeOutput_2024_06_14;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -315,6 +316,7 @@ describe("Assign all team members", () => {
|
||||
const secondHost = eventTypeHosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
expect(firstHost).toBeDefined();
|
||||
expect(secondHost).toBeDefined();
|
||||
collectiveEventType = data;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,6 +367,28 @@ describe("Assign all team members", () => {
|
||||
});
|
||||
|
||||
it("should update round robin event type", async () => {
|
||||
if (!roundRobinEventType) {
|
||||
const setupBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation round robin",
|
||||
slug: `assign-all-team-members-round-robin-${randomString()}`,
|
||||
lengthInMinutes: 60,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
schedulingType: "roundRobin",
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
const setupResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/teams/${managedTeam.id}/event-types`)
|
||||
.send(setupBody)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(201);
|
||||
|
||||
const setupResponseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = setupResponse.body;
|
||||
roundRobinEventType = setupResponseBody.data;
|
||||
}
|
||||
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation round robin updated",
|
||||
};
|
||||
@@ -467,10 +491,17 @@ describe("Assign all team members", () => {
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
expect(expected.priority).toEqual(received?.priority);
|
||||
function evaluateHost(expected: Partial<Host>, received: Host | undefined) {
|
||||
if (!received) {
|
||||
throw new Error(`Host is undefined. Expected userId: ${expected.userId}`);
|
||||
}
|
||||
expect(expected.userId).toEqual(received.userId);
|
||||
if (expected.mandatory !== undefined) {
|
||||
expect(expected.mandatory).toEqual(received.mandatory);
|
||||
}
|
||||
if (expected.priority !== undefined) {
|
||||
expect(expected.priority).toEqual(received.priority);
|
||||
}
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
|
||||
+50
@@ -1158,6 +1158,56 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a managed team event-type without hosts", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation managed without hosts",
|
||||
slug: "coding-consultation-managed-without-hosts",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "MANAGED",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
|
||||
.send(body)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
|
||||
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
|
||||
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
|
||||
const teammate1HasThisEvent = teammate1EventTypes.some((eventType) => eventType.slug === body.slug);
|
||||
const teammate2HasThisEvent = teammate2EventTypes.some((eventType) => eventType.slug === body.slug);
|
||||
expect(teammate1HasThisEvent).toBe(false);
|
||||
expect(teammate2HasThisEvent).toBe(false);
|
||||
expect(
|
||||
teamEventTypes.filter(
|
||||
(eventType) => eventType.schedulingType === "MANAGED" && eventType.slug === body.slug
|
||||
).length
|
||||
).toEqual(1);
|
||||
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.hosts).toEqual([]);
|
||||
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
|
||||
@@ -121,7 +121,7 @@ export class InputOrganizationsEventTypesService {
|
||||
},
|
||||
];
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventType(null, inputEventType, teamId);
|
||||
const children = await this.getChildEventTypesForManagedEventTypeCreate(inputEventType, teamId);
|
||||
|
||||
const metadata =
|
||||
rest.schedulingType === "MANAGED"
|
||||
@@ -159,7 +159,11 @@ export class InputOrganizationsEventTypesService {
|
||||
throw new BadRequestException("Event type to update not found");
|
||||
}
|
||||
|
||||
const children = await this.getChildEventTypesForManagedEventType(eventTypeId, inputEventType, teamId);
|
||||
const children =
|
||||
dbEventType.schedulingType === "MANAGED"
|
||||
? await this.getChildEventTypesForManagedEventTypeUpdate(eventTypeId, inputEventType, teamId)
|
||||
: undefined;
|
||||
|
||||
const teamEventType = {
|
||||
...eventType,
|
||||
// note(Lauris): we don't populate hosts for managed event-types because they are handled by the children
|
||||
@@ -176,20 +180,17 @@ export class InputOrganizationsEventTypesService {
|
||||
return teamEventType;
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventType(
|
||||
eventTypeId: number | null,
|
||||
async getChildEventTypesForManagedEventTypeUpdate(
|
||||
eventTypeId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
teamId: number
|
||||
) {
|
||||
let eventType = null;
|
||||
if (eventTypeId) {
|
||||
eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
|
||||
if (!eventType || eventType.schedulingType !== "MANAGED") {
|
||||
return undefined;
|
||||
}
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeByIdWithChildren(eventTypeId);
|
||||
if (!eventType || eventType.schedulingType !== "MANAGED") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventType(teamId, inputEventType, eventType);
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeUpdate(teamId, inputEventType, eventType);
|
||||
const owners = await this.getOwnersForManagedEventType(ownersIds);
|
||||
|
||||
return owners.map((owner) => {
|
||||
@@ -200,22 +201,58 @@ export class InputOrganizationsEventTypesService {
|
||||
});
|
||||
}
|
||||
|
||||
async getOwnersIdsForManagedEventType(
|
||||
async getOwnersIdsForManagedEventTypeUpdate(
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
eventType: { children: { userId: number | null }[] } | null
|
||||
eventType: { children: { userId: number | null }[] }
|
||||
) {
|
||||
if (inputEventType.assignAllTeamMembers) {
|
||||
return await this.getTeamUsersIds(teamId);
|
||||
}
|
||||
|
||||
// note(Lauris): when API user updates managed event type users
|
||||
if (inputEventType.hosts) {
|
||||
return inputEventType.hosts.map((host) => host.userId);
|
||||
}
|
||||
|
||||
// note(Lauris): when API user DOES NOT update managed event type users, but we still need existing managed event type users to know which event-types to update
|
||||
return eventType?.children.map((child) => child.userId).filter((id) => !!id) as number[];
|
||||
// e.g if managed event type title is changed then all children managed event types should be updated as well.
|
||||
const childrenOwnersIds: number[] = [];
|
||||
for (const child of eventType.children) {
|
||||
if (child.userId) {
|
||||
childrenOwnersIds.push(child.userId);
|
||||
}
|
||||
}
|
||||
return childrenOwnersIds;
|
||||
}
|
||||
|
||||
async getChildEventTypesForManagedEventTypeCreate(
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14,
|
||||
teamId: number
|
||||
) {
|
||||
const ownersIds = await this.getOwnersIdsForManagedEventTypeCreate(teamId, inputEventType);
|
||||
const owners = await this.getOwnersForManagedEventType(ownersIds);
|
||||
|
||||
return owners.map((owner) => {
|
||||
return {
|
||||
hidden: false,
|
||||
owner,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getOwnersIdsForManagedEventTypeCreate(
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
if (inputEventType.assignAllTeamMembers) {
|
||||
return await this.getTeamUsersIds(teamId);
|
||||
}
|
||||
|
||||
if (inputEventType.hosts) {
|
||||
return inputEventType.hosts.map((host) => host.userId);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getTeamUsersIds(teamId: number) {
|
||||
@@ -234,7 +271,7 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
|
||||
async getOwnersForManagedEventType(userIds: number[]) {
|
||||
const users = await this.usersRepository.findByIdsWithEventTypes(userIds);
|
||||
const users = userIds.length ? await this.usersRepository.findByIdsWithEventTypes(userIds) : [];
|
||||
|
||||
return users.map((user) => {
|
||||
const nonManagedEventTypes = user.eventTypes.filter((eventType) => !eventType.parentId);
|
||||
|
||||
+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
-3
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -24,6 +24,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 +46,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,
|
||||
|
||||
+23
-6
@@ -5,14 +5,14 @@ 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";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsService {
|
||||
@@ -40,8 +40,22 @@ export class ManagedOrganizationsService {
|
||||
|
||||
const { apiKeyDaysValid, apiKeyNeverExpires, ...organizationData } = organizationInput;
|
||||
|
||||
const effectiveSlug = organizationData.slug || slugify(organizationData.name);
|
||||
|
||||
if (!organizationData.slug) {
|
||||
organizationData.slug = slugify(organizationData.name);
|
||||
organizationData.slug = effectiveSlug;
|
||||
}
|
||||
|
||||
const existingManagedOrganization =
|
||||
await this.managedOrganizationsRepository.getManagedOrganizationBySlug(
|
||||
managerOrganizationId,
|
||||
effectiveSlug
|
||||
);
|
||||
|
||||
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(
|
||||
@@ -101,11 +115,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 +130,7 @@ export class ManagedOrganizationsService {
|
||||
this.managedOrganizationsOutputService.getOutputManagedOrganization(managedOrganization)
|
||||
),
|
||||
pagination: getPagination({
|
||||
...pagination,
|
||||
...query,
|
||||
totalCount: totalItems,
|
||||
}),
|
||||
};
|
||||
|
||||
+438
-207
@@ -23,27 +23,48 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
let org: Team;
|
||||
let team: Team;
|
||||
let apiKeyString: string;
|
||||
let routingForm: App_RoutingForms_Form;
|
||||
let routingFormResponse: App_RoutingForms_FormResponse;
|
||||
let routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
|
||||
// Grouped data structures
|
||||
let orgAdminData: {
|
||||
user: User;
|
||||
apiKey: string;
|
||||
eventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
};
|
||||
routingForm: App_RoutingForms_Form;
|
||||
};
|
||||
|
||||
let teamData: {
|
||||
team: Team;
|
||||
routingForm: App_RoutingForms_Form;
|
||||
routingFormResponse1: App_RoutingForms_FormResponse;
|
||||
routingFormResponse2: App_RoutingForms_FormResponse;
|
||||
};
|
||||
|
||||
let nonOrgAdminData: {
|
||||
user: User;
|
||||
apiKey: string;
|
||||
eventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
};
|
||||
routingForm: App_RoutingForms_Form;
|
||||
};
|
||||
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let routingEventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
};
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@@ -64,25 +85,34 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
isOrganization: true,
|
||||
});
|
||||
|
||||
team = await teamRepositoryFixture.create({
|
||||
// Initialize grouped data structures
|
||||
orgAdminData = {} as any;
|
||||
teamData = {} as any;
|
||||
nonOrgAdminData = {} as any;
|
||||
|
||||
teamData.team = await teamRepositoryFixture.create({
|
||||
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
orgAdminData.user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
});
|
||||
|
||||
nonOrgAdminData.user = await userRepositoryFixture.create({
|
||||
email: `non-org-admin-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
user: { connect: { id: orgAdminData.user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
uid: `usr-${orgAdminData.user.id}`,
|
||||
username: userEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
@@ -91,98 +121,166 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
id: orgAdminData.user.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() + 1);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgAdminData.user.id, null);
|
||||
orgAdminData.apiKey = `${keyString}`;
|
||||
|
||||
const { keyString: _nonOrgAdminUserApiKey } = await apiKeysRepositoryFixture.createApiKey(
|
||||
nonOrgAdminData.user.id,
|
||||
null
|
||||
);
|
||||
nonOrgAdminData.apiKey = `${_nonOrgAdminUserApiKey}`;
|
||||
|
||||
// Create an event type for routing form to route to
|
||||
routingEventType = await prismaWriteService.prisma.eventType.create({
|
||||
orgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
userId: orgAdminData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
nonOrgAdminData.eventType = await prismaWriteService.prisma.eventType.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: nonOrgAdminData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const routingFormData = {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: null,
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text",
|
||||
label: "Question 2",
|
||||
required: false,
|
||||
identifier: "question2",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
};
|
||||
|
||||
orgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
...routingFormData,
|
||||
routes: [
|
||||
{
|
||||
...routingFormData.routes[0],
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: routingEventType.id,
|
||||
value: `team/${team.slug}/${routingEventType.slug}`,
|
||||
eventTypeId: orgAdminData.eventType.id,
|
||||
value: `${orgAdminData.user.username}/${orgAdminData.eventType.slug}`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
routingFormData.routes[1],
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text",
|
||||
label: "Question 2",
|
||||
required: false,
|
||||
identifier: "question2",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
userId: orgAdminData.user.id,
|
||||
// User Routing Form has teamId=null
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
nonOrgAdminData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
...routingFormData,
|
||||
routes: [
|
||||
{
|
||||
...routingFormData.routes[0],
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: nonOrgAdminData.eventType.id,
|
||||
value: `${nonOrgAdminData.user.username}/${nonOrgAdminData.eventType.slug}`,
|
||||
},
|
||||
},
|
||||
routingFormData.routes[1],
|
||||
],
|
||||
userId: nonOrgAdminData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Patch response and get Responses endpoints right now work for teams only
|
||||
// We need to fix them in a followup PR
|
||||
teamData.routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
...routingFormData,
|
||||
routes: [
|
||||
{
|
||||
...routingFormData.routes[0],
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: orgAdminData.eventType.id,
|
||||
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
|
||||
},
|
||||
},
|
||||
routingFormData.routes[1],
|
||||
],
|
||||
userId: orgAdminData.user.id,
|
||||
teamId: teamData.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
teamData.routingFormResponse1 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: teamData.routingForm.id,
|
||||
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
|
||||
},
|
||||
});
|
||||
|
||||
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
teamData.routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formId: routingForm.id,
|
||||
formId: teamData.routingForm.id,
|
||||
response: { question1: "answer1", question2: "answer2" },
|
||||
},
|
||||
});
|
||||
@@ -196,33 +294,33 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should not get routing form responses for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.get(`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should not get routing form responses for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not get routing form responses without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
|
||||
.get(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses`)
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should get routing form responses", async () => {
|
||||
const createdAt = new Date(routingFormResponse.createdAt);
|
||||
const createdAt = new Date(teamData.routingFormResponse1.createdAt);
|
||||
createdAt.setHours(createdAt.getHours() - 1);
|
||||
const isoStringCreatedAt = createdAt.toISOString();
|
||||
return request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
|
||||
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc&afterCreatedAt=${isoStringCreatedAt}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
@@ -230,14 +328,18 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
const responses = responseBody.data as App_RoutingForms_FormResponse[];
|
||||
expect(responses).toBeDefined();
|
||||
expect(responses.length).toBeGreaterThan(0);
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
|
||||
routingFormResponse.formId
|
||||
);
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
|
||||
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
|
||||
routingFormResponse2.formId
|
||||
);
|
||||
expect(
|
||||
responses.find((response) => response.id === teamData.routingFormResponse1.id)
|
||||
).toBeDefined();
|
||||
expect(
|
||||
responses.find((response) => response.id === teamData.routingFormResponse1.id)?.formId
|
||||
).toEqual(teamData.routingFormResponse1.formId);
|
||||
expect(
|
||||
responses.find((response) => response.id === teamData.routingFormResponse2.id)
|
||||
).toBeDefined();
|
||||
expect(
|
||||
responses.find((response) => response.id === teamData.routingFormResponse2.id)?.formId
|
||||
).toEqual(teamData.routingFormResponse2.formId);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -246,9 +348,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 403 when organization does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/99999/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
@@ -260,7 +362,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
@@ -270,7 +372,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 401 when authentication token is missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
@@ -281,9 +383,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should create response and return available slots when routing to event type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
@@ -296,7 +398,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.eventTypeId).toEqual(orgAdminData.eventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
expect(typeof data.slots).toBe("object");
|
||||
});
|
||||
@@ -305,9 +407,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 400 when required form fields are missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question2: "answer2", // Missing required question1
|
||||
})
|
||||
@@ -317,9 +419,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should create response and return custom message if the routing is to custom page", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "different-answer", // This won't match any route
|
||||
question2: "answer2",
|
||||
@@ -338,8 +440,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 400 when required slot query parameters are missing", async () => {
|
||||
// Missing start parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
@@ -348,8 +452,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
|
||||
// Missing end parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
@@ -360,9 +466,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 400 when date parameters have invalid format", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=invalid-date&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
@@ -372,9 +478,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should return 400 when end date is before start date", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
@@ -394,7 +500,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
|
||||
.send({
|
||||
@@ -412,9 +518,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should handle queued response creation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
@@ -430,67 +536,69 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
|
||||
it("should return 500 when event type is not found", async () => {
|
||||
// Create a routing form with an invalid eventTypeId
|
||||
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form with Invalid Event Type",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
const orgAdminUserRoutingFormWithInvalidEventType =
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form with Invalid Event Type",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: 99999, // Invalid event type ID
|
||||
value: `team/${teamData.team.slug}/non-existent-event-type`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: 99999, // Invalid event type ID
|
||||
value: `team/${team.slug}/non-existent-event-type`,
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
isFallback: false,
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
|
||||
teamId: null,
|
||||
userId: orgAdminData.user.id,
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Try to create a response for the form with invalid event type
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminUserRoutingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
@@ -499,37 +607,10 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
|
||||
// Clean up the form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: routingFormWithInvalidEventType.id },
|
||||
where: { id: orgAdminUserRoutingFormWithInvalidEventType.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle routing with team member assignments", async () => {
|
||||
// This test verifies that routing forms can handle team member assignments
|
||||
// and that the routing returns the correct team member information
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
// Team member assignments would be part of the routing data
|
||||
// This test validates the basic routing functionality works
|
||||
});
|
||||
});
|
||||
|
||||
it("should return external redirect URL when routing to external URL", async () => {
|
||||
// Create a routing form with external redirect action
|
||||
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
@@ -581,8 +662,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
teamId: null,
|
||||
userId: orgAdminData.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -590,7 +671,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({
|
||||
question1: "external", // This matches the route condition for external redirect
|
||||
})
|
||||
@@ -617,11 +698,161 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses - restrictions check`, () => {
|
||||
// Helper functions to centralize API version header setting
|
||||
const createAuthenticatedRequest = (method: "get" | "post" | "patch", url: string, apiKey: string) => {
|
||||
return request(app.getHttpServer())
|
||||
[method](url)
|
||||
.set({
|
||||
Authorization: `Bearer cal_test_${apiKey}`,
|
||||
});
|
||||
};
|
||||
|
||||
it("should allow non-org-admin user to access their own user routing form", async () => {
|
||||
return createAuthenticatedRequest(
|
||||
"post",
|
||||
`/v2/organizations/${org.id}/routing-forms/${nonOrgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
|
||||
nonOrgAdminData.apiKey
|
||||
)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(nonOrgAdminData.eventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
expect(typeof data.slots).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when non-org-admin user tries to access routing form they don't own", async () => {
|
||||
// Create a second user
|
||||
const otherUser = await userRepositoryFixture.create({
|
||||
email: `other-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
// Create a routing form that belongs to the other user
|
||||
const otherorgAdminUserRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Other User's Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: orgAdminData.eventType.id,
|
||||
value: `team/${teamData.team.slug}/${orgAdminData.eventType.slug}`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
// User Routing Form has teamId=null
|
||||
teamId: null,
|
||||
userId: otherUser.id, // This form belongs to otherUser, not the authenticated user
|
||||
},
|
||||
});
|
||||
|
||||
// Try to access the routing form that belongs to the other user
|
||||
const response = await createAuthenticatedRequest(
|
||||
"post",
|
||||
`/v2/organizations/${org.id}/routing-forms/${otherorgAdminUserRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
|
||||
nonOrgAdminData.apiKey
|
||||
).send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toContain(
|
||||
`Routing Form with id=${otherorgAdminUserRoutingForm.id} is not a user Routing Form owned by user with id=${nonOrgAdminData.user.id}.`
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: otherorgAdminUserRoutingForm.id },
|
||||
});
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: { id: otherUser.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should allow org admin to access any routing form using RolesGuard", async () => {
|
||||
return createAuthenticatedRequest(
|
||||
"post",
|
||||
`/v2/organizations/${org.id}/routing-forms/${orgAdminData.routingForm.id}/responses?start=2050-09-05&end=2050-09-06`,
|
||||
orgAdminData.apiKey
|
||||
)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up user routing form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: orgAdminData.routingForm.id },
|
||||
});
|
||||
|
||||
// Clean up non-org-admin user
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: { id: nonOrgAdminData.user.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.patch(
|
||||
`/v2/organizations/99999/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(403);
|
||||
});
|
||||
@@ -629,17 +860,17 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should not update routing form response for non existing form", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${teamData.routingFormResponse1.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not update routing form response for non existing response", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.patch(`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/99999`)
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(404);
|
||||
});
|
||||
@@ -647,7 +878,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
it("should not update routing form response without authentication", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
|
||||
)
|
||||
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
|
||||
.expect(401);
|
||||
@@ -657,9 +888,9 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
|
||||
return request(app.getHttpServer())
|
||||
.patch(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
|
||||
`/v2/organizations/${org.id}/routing-forms/${teamData.routingForm.id}/responses/${teamData.routingFormResponse1.id}`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.set({ Authorization: `Bearer cal_test_${orgAdminData.apiKey}` })
|
||||
.send({ response: updatedResponse })
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
@@ -667,8 +898,8 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.id).toEqual(routingFormResponse.id);
|
||||
expect(data.formId).toEqual(routingFormResponse.formId);
|
||||
expect(data.id).toEqual(teamData.routingFormResponse1.id);
|
||||
expect(data.formId).toEqual(teamData.routingFormResponse1.formId);
|
||||
expect(data.response).toEqual(updatedResponse);
|
||||
});
|
||||
});
|
||||
@@ -677,12 +908,12 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
afterAll(async () => {
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: {
|
||||
id: routingFormResponse.id,
|
||||
id: teamData.routingFormResponse1.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: {
|
||||
id: routingFormResponse2.id,
|
||||
id: teamData.routingFormResponse2.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
|
||||
@@ -697,7 +928,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
where: {
|
||||
id: team.id,
|
||||
id: teamData.team.id,
|
||||
},
|
||||
});
|
||||
await prismaWriteService.prisma.team.delete({
|
||||
@@ -707,7 +938,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: {
|
||||
id: user.id,
|
||||
id: orgAdminData.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+7
-3
@@ -4,8 +4,10 @@ import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.de
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { Or } from "@/modules/auth/guards/or-guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
@@ -20,12 +22,12 @@ import {
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
Req,
|
||||
Version,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
import { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
|
||||
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
|
||||
@@ -37,7 +39,7 @@ import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-
|
||||
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@ApiTags("Orgs / Routing forms")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsRoutingFormsResponsesController {
|
||||
@@ -48,6 +50,7 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get routing form responses" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(RolesGuard)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async getRoutingFormResponses(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -74,6 +77,7 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create routing form response and get available slots" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(Or([RolesGuard, IsUserRoutingForm]))
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@@ -82,7 +86,6 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
@Req() request: Request
|
||||
): Promise<CreateRoutingFormResponseOutput> {
|
||||
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
|
||||
orgId,
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
@@ -97,6 +100,7 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@UseGuards(RolesGuard)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async updateRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
|
||||
+6
@@ -1,3 +1,5 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { IsUserRoutingForm } from "@/modules/auth/guards/organizations/is-user-routing-form.guard";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
@@ -14,17 +16,21 @@ import { OrganizationsRoutingFormsController } from "./controllers/organizations
|
||||
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
|
||||
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||
import { SharedRoutingFormResponseService } from "./services/shared-routing-form-response.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
|
||||
providers: [
|
||||
IsUserRoutingForm,
|
||||
MembershipsRepository,
|
||||
OrganizationsRepository,
|
||||
OrganizationsRoutingFormsRepository,
|
||||
OrganizationsRoutingFormsService,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
SharedRoutingFormResponseService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
TeamsEventTypesRepository,
|
||||
EventTypesRepository_2024_06_14,
|
||||
],
|
||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
||||
})
|
||||
|
||||
+15
-210
@@ -1,18 +1,9 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/platform-libraries";
|
||||
import { ById_2024_09_04_type } from "@calcom/platform-types";
|
||||
|
||||
import type { CreateRoutingFormResponseInput } from "../inputs/create-routing-form-response.input";
|
||||
import type { CreateRoutingFormResponseOutputData } from "../outputs/create-routing-form-response.output";
|
||||
|
||||
@@ -21,8 +12,7 @@ export class OrganizationsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
private readonly slotsService: SlotsService_2024_09_04,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
|
||||
) {}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
@@ -51,6 +41,18 @@ export class OrganizationsRoutingFormsResponsesService {
|
||||
return this.outputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
|
||||
async createRoutingFormResponseWithSlots(
|
||||
routingFormId: string,
|
||||
query: CreateRoutingFormResponseInput,
|
||||
request: Request
|
||||
): Promise<CreateRoutingFormResponseOutputData> {
|
||||
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
async updateRoutingFormResponse(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
@@ -68,201 +70,4 @@ export class OrganizationsRoutingFormsResponsesService {
|
||||
|
||||
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
|
||||
}
|
||||
|
||||
async createRoutingFormResponseWithSlots(
|
||||
orgId: number,
|
||||
routingFormId: string,
|
||||
query: CreateRoutingFormResponseInput,
|
||||
request: Request
|
||||
): Promise<CreateRoutingFormResponseOutputData> {
|
||||
const { queueResponse, ...slotsQuery } = query;
|
||||
|
||||
this.validateDateRange(slotsQuery.start, slotsQuery.end);
|
||||
|
||||
const { redirectUrl, customMessage } = await this.getRoutingUrl(
|
||||
request,
|
||||
routingFormId,
|
||||
queueResponse ?? false
|
||||
);
|
||||
|
||||
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
|
||||
if (!redirectUrl) {
|
||||
return {
|
||||
routingCustomMessage: customMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
|
||||
return {
|
||||
routingExternalRedirectUrl: redirectUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract event type information from the routed URL
|
||||
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(redirectUrl);
|
||||
|
||||
const paramsForGetAvailableSlots = {
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
...slotsQuery,
|
||||
...crmParams,
|
||||
} as const;
|
||||
|
||||
// Get available slots using the slots service with CRM parameters
|
||||
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
|
||||
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
|
||||
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
|
||||
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
|
||||
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
|
||||
const responseId = crmParams.routingFormResponseId ?? null;
|
||||
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
|
||||
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
|
||||
|
||||
if (responseId) {
|
||||
return {
|
||||
routing: {
|
||||
responseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
if (!queuedResponseId) {
|
||||
throw new InternalServerErrorException(
|
||||
"No routing form response ID or queued form response ID could be found."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
routing: {
|
||||
queuedResponseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
private validateDateRange(start: string, end: string) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
if (endDate < startDate) {
|
||||
throw new BadRequestException("End date cannot be before start date.");
|
||||
}
|
||||
}
|
||||
|
||||
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
|
||||
const params = Object.fromEntries(new URLSearchParams(request.body));
|
||||
const routedUrlData = await getRoutedUrl(
|
||||
{
|
||||
req: request,
|
||||
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
if (routedUrlData.notFound) {
|
||||
throw new NotFoundException("Routing form not found.");
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.errorMessage) {
|
||||
throw new BadRequestException(routedUrlData.props.errorMessage);
|
||||
}
|
||||
|
||||
const destination = routedUrlData?.redirect?.destination;
|
||||
|
||||
if (!destination) {
|
||||
if (routedUrlData?.props?.message) {
|
||||
return {
|
||||
redirectUrl: null,
|
||||
customMessage: routedUrlData.props.message,
|
||||
};
|
||||
}
|
||||
// This should never happen because there is always a fallback route
|
||||
throw new InternalServerErrorException("No route found.");
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: new URL(destination),
|
||||
customMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async extractEventTypeAndCrmParams(routingUrl: URL) {
|
||||
// Extract team and event type information
|
||||
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug);
|
||||
|
||||
if (!eventType?.id) {
|
||||
// This could only happen if the event-type earlier selected as route action was deleted
|
||||
throw new InternalServerErrorException("Chosen event type not found.");
|
||||
}
|
||||
|
||||
// Extract CRM parameters from URL
|
||||
const urlParams = routingUrl.searchParams;
|
||||
const crmParams = {
|
||||
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
|
||||
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
|
||||
? urlParams
|
||||
.get("cal.routedTeamMemberIds")!
|
||||
.split(",")
|
||||
.map((id) => parseInt(id))
|
||||
: undefined,
|
||||
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
|
||||
? parseInt(urlParams.get("cal.routingFormResponseId")!)
|
||||
: undefined,
|
||||
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
|
||||
? (urlParams.get("cal.queuedFormResponseId") as string)
|
||||
: undefined,
|
||||
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
|
||||
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
|
||||
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
eventTypeId: eventType.id,
|
||||
crmParams,
|
||||
};
|
||||
}
|
||||
|
||||
private isEventTypeRedirectUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
|
||||
}
|
||||
|
||||
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
|
||||
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
|
||||
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
|
||||
|
||||
if (!teamId) {
|
||||
throw new NotFoundException("Team ID not found in the routed URL.");
|
||||
}
|
||||
|
||||
if (!eventTypeSlug) {
|
||||
throw new NotFoundException("Event type slug not found in the routed URL.");
|
||||
}
|
||||
|
||||
return { teamId, eventTypeSlug };
|
||||
}
|
||||
|
||||
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return Number(routingSearchParams.get("cal.teamId"));
|
||||
}
|
||||
|
||||
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
return pathNameParams[pathNameParams.length - 1];
|
||||
}
|
||||
}
|
||||
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
|
||||
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
|
||||
import { SlotsService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/platform-libraries";
|
||||
import { ById_2024_09_04_type } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class SharedRoutingFormResponseService {
|
||||
constructor(
|
||||
private readonly slotsService: SlotsService_2024_09_04,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14
|
||||
) {}
|
||||
|
||||
async createRoutingFormResponseWithSlots(
|
||||
routingFormId: string,
|
||||
query: CreateRoutingFormResponseInput,
|
||||
request: Request
|
||||
): Promise<CreateRoutingFormResponseOutputData> {
|
||||
const { queueResponse, ...slotsQuery } = query;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
|
||||
this.validateDateRange(slotsQuery.start, slotsQuery.end);
|
||||
|
||||
const { redirectUrl, customMessage } = await this.getRoutingUrl(
|
||||
request,
|
||||
routingFormId,
|
||||
queueResponse ?? false
|
||||
);
|
||||
|
||||
// If there is no redirect URL, then we have to show the message as that would be custom page message to be shown as per the route chosen
|
||||
if (!redirectUrl) {
|
||||
return {
|
||||
routingCustomMessage: customMessage,
|
||||
};
|
||||
}
|
||||
|
||||
if (!this.isEventTypeRedirectUrl(redirectUrl)) {
|
||||
return {
|
||||
routingExternalRedirectUrl: redirectUrl.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Extract event type information from the routed URL
|
||||
const { eventTypeId, crmParams } = await this.extractEventTypeAndCrmParams(user.id, redirectUrl);
|
||||
|
||||
const paramsForGetAvailableSlots = {
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
...slotsQuery,
|
||||
...crmParams,
|
||||
} as const;
|
||||
|
||||
// Get available slots using the slots service with CRM parameters
|
||||
const slots = await this.slotsService.getAvailableSlots(paramsForGetAvailableSlots);
|
||||
const teamMemberIds = crmParams.routedTeamMemberIds ?? [];
|
||||
const teamMemberEmail = crmParams.teamMemberEmail ?? undefined;
|
||||
const skipContactOwner = crmParams.skipContactOwner ?? undefined;
|
||||
const queuedResponseId = crmParams.queuedFormResponseId ?? null;
|
||||
const responseId = crmParams.routingFormResponseId ?? null;
|
||||
const crmAppSlug = crmParams.crmAppSlug ?? undefined;
|
||||
const crmOwnerRecordType = crmParams.crmOwnerRecordType ?? undefined;
|
||||
|
||||
if (responseId) {
|
||||
return {
|
||||
routing: {
|
||||
responseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
if (!queuedResponseId) {
|
||||
throw new InternalServerErrorException(
|
||||
"No routing form response ID or queued form response ID could be found."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
routing: {
|
||||
queuedResponseId,
|
||||
teamMemberEmail,
|
||||
teamMemberIds,
|
||||
skipContactOwner,
|
||||
crmAppSlug,
|
||||
crmOwnerRecordType,
|
||||
},
|
||||
eventTypeId,
|
||||
slots,
|
||||
};
|
||||
}
|
||||
|
||||
private validateDateRange(start: string, end: string) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
if (endDate < startDate) {
|
||||
throw new BadRequestException("End date cannot be before start date.");
|
||||
}
|
||||
}
|
||||
|
||||
private async getRoutingUrl(request: Request, formId: string, queueResponse: boolean) {
|
||||
const params = Object.fromEntries(new URLSearchParams(request.body));
|
||||
const routedUrlData = await getRoutedUrl(
|
||||
{
|
||||
req: request,
|
||||
query: { ...params, form: formId, ...(queueResponse && { "cal.queueFormResponse": "true" }) },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
if (routedUrlData.notFound) {
|
||||
throw new NotFoundException("Routing form not found.");
|
||||
}
|
||||
|
||||
if (routedUrlData?.props?.errorMessage) {
|
||||
throw new BadRequestException(routedUrlData.props.errorMessage);
|
||||
}
|
||||
|
||||
const destination = routedUrlData?.redirect?.destination;
|
||||
|
||||
if (!destination) {
|
||||
if (routedUrlData?.props?.message) {
|
||||
return {
|
||||
redirectUrl: null,
|
||||
customMessage: routedUrlData.props.message,
|
||||
};
|
||||
}
|
||||
// This should never happen because there is always a fallback route
|
||||
throw new InternalServerErrorException("No route found.");
|
||||
}
|
||||
|
||||
return {
|
||||
redirectUrl: new URL(destination),
|
||||
customMessage: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async extractEventTypeAndCrmParams(userId: number, routingUrl: URL) {
|
||||
// Extract team and event type information
|
||||
// TODO: Route action also has eventTypeId directly now and instead of using this brittle approach for getting event type by slug, we should get by eventTypeId
|
||||
const { teamId, eventTypeSlug } = this.extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl);
|
||||
const eventType = teamId
|
||||
? await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlug(teamId, eventTypeSlug)
|
||||
: await this.eventTypesRepository.getUserEventTypeBySlug(userId, eventTypeSlug);
|
||||
|
||||
if (!eventType?.id) {
|
||||
// This could only happen if the event-type earlier selected as route action was deleted
|
||||
throw new InternalServerErrorException(
|
||||
`Chosen event type identified by slug ${eventTypeSlug} not found.`
|
||||
);
|
||||
}
|
||||
|
||||
// Extract CRM parameters from URL
|
||||
const urlParams = routingUrl.searchParams;
|
||||
const crmParams = {
|
||||
teamMemberEmail: urlParams.get("cal.crmContactOwnerEmail") || undefined,
|
||||
routedTeamMemberIds: urlParams.get("cal.routedTeamMemberIds")
|
||||
? urlParams
|
||||
.get("cal.routedTeamMemberIds")!
|
||||
.split(",")
|
||||
.map((id) => parseInt(id))
|
||||
: undefined,
|
||||
routingFormResponseId: urlParams.get("cal.routingFormResponseId")
|
||||
? parseInt(urlParams.get("cal.routingFormResponseId")!)
|
||||
: undefined,
|
||||
queuedFormResponseId: urlParams.get("cal.queuedFormResponseId")
|
||||
? (urlParams.get("cal.queuedFormResponseId") as string)
|
||||
: undefined,
|
||||
skipContactOwner: urlParams.get("cal.skipContactOwner") === "true" ? true : false,
|
||||
crmAppSlug: urlParams.get("cal.crmAppSlug") || undefined,
|
||||
crmOwnerRecordType: urlParams.get("cal.crmContactOwnerRecordType") || undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
eventTypeId: eventType.id,
|
||||
crmParams,
|
||||
};
|
||||
}
|
||||
|
||||
private isEventTypeRedirectUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
return routingSearchParams.get("cal.action") === "eventTypeRedirectUrl";
|
||||
}
|
||||
|
||||
private extractTeamIdAndEventTypeSlugFromRedirectUrl(routingUrl: URL) {
|
||||
const eventTypeSlug = this.extractEventTypeFromRoutedUrl(routingUrl);
|
||||
const teamId = this.extractTeamIdFromRoutedUrl(routingUrl);
|
||||
|
||||
if (!eventTypeSlug) {
|
||||
throw new InternalServerErrorException("Event type slug not found in the routed URL.");
|
||||
}
|
||||
|
||||
return { teamId, eventTypeSlug };
|
||||
}
|
||||
|
||||
private extractTeamIdFromRoutedUrl(routingUrl: URL) {
|
||||
const routingSearchParams = routingUrl.searchParams;
|
||||
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
||||
if (isNaN(teamId)) {
|
||||
return null;
|
||||
}
|
||||
return teamId;
|
||||
}
|
||||
|
||||
private extractEventTypeFromRoutedUrl(routingUrl: URL) {
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
return pathNameParams[pathNameParams.length - 1];
|
||||
}
|
||||
}
|
||||
+522
-6
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -23,6 +24,7 @@ import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Organizations Teams Routing Forms Responses", () => {
|
||||
let app: INestApplication;
|
||||
let prismaWriteService: PrismaWriteService;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
@@ -41,6 +43,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
let apiKeyString: string;
|
||||
|
||||
let routingFormId: string;
|
||||
let routingEventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
};
|
||||
const routingFormResponses = [
|
||||
{
|
||||
formFillerId: `${randomString()}`,
|
||||
@@ -59,6 +68,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
}).compile();
|
||||
|
||||
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
@@ -98,6 +108,17 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
team: { connect: { id: orgTeam.id } },
|
||||
});
|
||||
|
||||
// Create an event type for routing form to route to
|
||||
routingEventType = await prismaWriteService.prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 30,
|
||||
userId: user.id,
|
||||
teamId: orgTeam.id,
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${user.id}`,
|
||||
username: authEmail,
|
||||
@@ -118,18 +139,59 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
description: null,
|
||||
position: 0,
|
||||
disabled: false,
|
||||
fields: JSON.stringify([
|
||||
fields: [
|
||||
{
|
||||
id: "participant-field",
|
||||
type: "text",
|
||||
label: "participant",
|
||||
required: true,
|
||||
identifier: "participant",
|
||||
},
|
||||
]),
|
||||
routes: JSON.stringify([
|
||||
{
|
||||
action: { type: "customPageMessage", value: "Thank you for your response" },
|
||||
id: "question2-field",
|
||||
type: "text",
|
||||
label: "question2",
|
||||
required: false,
|
||||
identifier: "question2",
|
||||
},
|
||||
]),
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: routingEventType.id,
|
||||
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
queryValue: {
|
||||
id: "fallback-route",
|
||||
type: "group",
|
||||
children1: {},
|
||||
},
|
||||
action: { type: "customPageMessage", value: "Thank you for your response" },
|
||||
isFallback: true,
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
@@ -190,6 +252,455 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(`POST /v2/organizations/:orgId/teams/:teamId/routing-forms/:routingFormId/responses`, () => {
|
||||
describe("permissions", () => {
|
||||
it("should return 403 when organization does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/99999/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should return 404 when team does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/99999/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(404)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toContain(`IsTeamInOrg - Team (99999) not found.`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when routing form does not exist in the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(403)
|
||||
.then((response) => {
|
||||
expect(response.body.error.message).toContain(
|
||||
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(non-existent-id).`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 403 when routing form belongs to different team", async () => {
|
||||
// Create a second team within the same organization
|
||||
const otherTeam = await teamsRepositoryFixture.create({
|
||||
name: `other-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: org.id } },
|
||||
});
|
||||
|
||||
// Create a routing form that belongs to the other team
|
||||
const otherTeamRoutingForm = await routingFormsRepositoryFixture.create({
|
||||
name: "Other Team's Routing Form",
|
||||
description: "Test Description",
|
||||
position: 0,
|
||||
disabled: false,
|
||||
fields: [
|
||||
{
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
action: { type: "customPageMessage", value: "Thank you for your response" },
|
||||
},
|
||||
],
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
team: {
|
||||
connect: {
|
||||
id: otherTeam.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Try to access the routing form that belongs to the other team
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${otherTeamRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.error.message).toContain(
|
||||
`IsRoutingFormInTeam - team with id=(${orgTeam.id}) does not own routing form with id=(${otherTeamRoutingForm.id}).`
|
||||
);
|
||||
|
||||
// Clean up
|
||||
await routingFormsRepositoryFixture.delete(otherTeamRoutingForm.id);
|
||||
await teamsRepositoryFixture.delete(otherTeam.id);
|
||||
});
|
||||
|
||||
it("should return 401 when authentication token is missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when required form fields are missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
// Missing required participant field
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when required slot query parameters are missing", async () => {
|
||||
// Missing start parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
participant: "test-participant",
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
// Missing end parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
participant: "test-participant",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when date parameters have invalid format", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=invalid-date&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
participant: "test-participant",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when end date is before start date", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-10&end=2050-09-05`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
participant: "test-participant",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should handle queued response creation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
participant: "test-participant",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
console.log("responseBody", responseBody.data);
|
||||
expect(data.routing?.queuedResponseId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should create response and return available slots when routing to event type", async () => {
|
||||
// Create a routing form with event type routing
|
||||
const eventTypeRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Event Type Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: routingEventType.id,
|
||||
value: `team/${orgTeam.slug}/${routingEventType.slug}`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
{
|
||||
id: "question2",
|
||||
type: "text",
|
||||
label: "Question 2",
|
||||
required: false,
|
||||
identifier: "question2",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
teamId: orgTeam.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${eventTypeRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
expect(typeof data.slots).toBe("object");
|
||||
expect(data.routing?.teamMemberIds).toBeDefined();
|
||||
|
||||
// Clean up
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: eventTypeRoutingForm.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 500 when event type is not found", async () => {
|
||||
// Create a routing form with an invalid eventTypeId
|
||||
const routingFormWithInvalidEventType = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form with Invalid Event Type",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "route-1",
|
||||
queryValue: {
|
||||
id: "route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["answer1"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "eventTypeRedirectUrl",
|
||||
eventTypeId: 99999, // Invalid event type ID
|
||||
value: `team/${orgTeam.slug}/non-existent-event-type`,
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
teamId: orgTeam.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create a response for the form with invalid event type
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
|
||||
// Clean up the form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: routingFormWithInvalidEventType.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return external redirect URL when routing to external URL", async () => {
|
||||
// Create a routing form with external redirect action
|
||||
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test External Routing Form",
|
||||
description: "Test Description for External Redirect",
|
||||
disabled: false,
|
||||
routes: [
|
||||
{
|
||||
id: "external-route-1",
|
||||
queryValue: {
|
||||
id: "external-route-1",
|
||||
type: "group",
|
||||
children1: {
|
||||
"rule-1": {
|
||||
type: "rule",
|
||||
properties: {
|
||||
field: "question1",
|
||||
operator: "equal",
|
||||
value: ["external"],
|
||||
valueSrc: ["value"],
|
||||
valueType: ["text"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
action: {
|
||||
type: "externalRedirectUrl",
|
||||
value: "https://example.com/external-booking",
|
||||
},
|
||||
isFallback: false,
|
||||
},
|
||||
{
|
||||
id: "fallback-route",
|
||||
action: { type: "customPageMessage", value: "Fallback Message" },
|
||||
isFallback: true,
|
||||
queryValue: { id: "fallback-route", type: "group" },
|
||||
},
|
||||
],
|
||||
fields: [
|
||||
{
|
||||
id: "question1",
|
||||
type: "text",
|
||||
label: "Question 1",
|
||||
required: true,
|
||||
identifier: "question1",
|
||||
},
|
||||
],
|
||||
settings: {
|
||||
emailOwnerOnSubmission: false,
|
||||
},
|
||||
teamId: orgTeam.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${externalRoutingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "external", // This matches the route condition for external redirect
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routingExternalRedirectUrl).toBeDefined();
|
||||
expect(data.routingExternalRedirectUrl).toContain("https://example.com/external-booking");
|
||||
expect(data.routingExternalRedirectUrl).toContain("cal.action=externalRedirectUrl");
|
||||
|
||||
// Verify that it doesn't contain event type routing data
|
||||
expect(data.eventTypeId).toBeUndefined();
|
||||
expect(data.slots).toBeUndefined();
|
||||
expect(data.routing).toBeUndefined();
|
||||
expect(data.routingCustomMessage).toBeUndefined();
|
||||
|
||||
// Clean up the external routing form
|
||||
await prismaWriteService.prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: externalRoutingForm.id },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
|
||||
it("should not update routing form response for non existing org", async () => {
|
||||
return request(app.getHttpServer())
|
||||
@@ -246,8 +757,13 @@ describe("Organizations Teams Routing Forms Responses", () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||
if (routingFormResponseId) {
|
||||
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
|
||||
}
|
||||
await routingFormsRepositoryFixture.delete(routingFormId);
|
||||
await prismaWriteService.prisma.eventType.delete({
|
||||
where: { id: routingEventType.id },
|
||||
});
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await organizationsRepositoryFixture.delete(org.id);
|
||||
await app.close();
|
||||
|
||||
+39
-1
@@ -9,13 +9,27 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
|
||||
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
|
||||
import { UpdateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/update-routing-form-response.input";
|
||||
import { CreateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
|
||||
import { UpdateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/update-routing-form-response.output";
|
||||
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses.service";
|
||||
import { Controller, Get, Patch, Param, ParseIntPipe, Query, UseGuards, Body } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
UseGuards,
|
||||
Body,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -66,6 +80,30 @@ export class OrganizationsTeamsRoutingFormsResponsesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create routing form response and get available slots" })
|
||||
@Roles("TEAM_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Query() query: CreateRoutingFormResponseInput,
|
||||
@Req() request: Request
|
||||
): Promise<CreateRoutingFormResponseOutput> {
|
||||
const result =
|
||||
await this.organizationsTeamsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
|
||||
+4
@@ -1,7 +1,9 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
|
||||
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
@@ -31,6 +33,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
|
||||
providers: [
|
||||
OrganizationsTeamsRoutingFormsService,
|
||||
OrganizationsTeamsRoutingFormsResponsesService,
|
||||
SharedRoutingFormResponseService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
OrganizationsTeamsRoutingFormsResponsesRepository,
|
||||
OrganizationsTeamsRoutingFormsRepository,
|
||||
@@ -39,6 +42,7 @@ import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-
|
||||
MembershipsRepository,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsRoutingFormsRepository,
|
||||
EventTypesRepository_2024_06_14,
|
||||
],
|
||||
controllers: [OrganizationsTeamsRoutingFormsResponsesController, OrganizationsTeamsRoutingFormsController],
|
||||
})
|
||||
|
||||
+18
-1
@@ -1,5 +1,9 @@
|
||||
import { CreateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/create-routing-form-response.input";
|
||||
import { CreateRoutingFormResponseOutputData } from "@/modules/organizations/routing-forms/outputs/create-routing-form-response.output";
|
||||
import { SharedRoutingFormResponseService } from "@/modules/organizations/routing-forms/services/shared-routing-form-response.service";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositories/organizations-teams-routing-forms-responses.repository";
|
||||
|
||||
@@ -7,7 +11,8 @@ import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositori
|
||||
export class OrganizationsTeamsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsResponsesRepository,
|
||||
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
private readonly sharedRoutingFormResponseService: SharedRoutingFormResponseService
|
||||
) {}
|
||||
|
||||
async getTeamRoutingFormResponses(
|
||||
@@ -36,6 +41,18 @@ export class OrganizationsTeamsRoutingFormsResponsesService {
|
||||
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
|
||||
}
|
||||
|
||||
async createRoutingFormResponseWithSlots(
|
||||
routingFormId: string,
|
||||
query: CreateRoutingFormResponseInput,
|
||||
request: Request
|
||||
): Promise<CreateRoutingFormResponseOutputData> {
|
||||
return this.sharedRoutingFormResponseService.createRoutingFormResponseWithSlots(
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
);
|
||||
}
|
||||
|
||||
async updateTeamRoutingFormResponse(
|
||||
teamId: number,
|
||||
routingFormId: string,
|
||||
|
||||
+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(
|
||||
|
||||
+92
-31
@@ -61,6 +61,48 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
|
||||
let managedEventType: TeamEventTypeOutput_2024_06_14;
|
||||
|
||||
async function ensureManagedEventType(): Promise<TeamEventTypeOutput_2024_06_14> {
|
||||
if (!managedEventType) {
|
||||
const setupBody: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-managed-${randomString()}`,
|
||||
slug: `teams-event-types-managed-${randomString()}`,
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "cal-video",
|
||||
},
|
||||
],
|
||||
schedulingType: "MANAGED",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
mandatory: true,
|
||||
priority: "high",
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
mandatory: false,
|
||||
priority: "low",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const setupResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/event-types`)
|
||||
.send(setupBody)
|
||||
.expect(201);
|
||||
|
||||
const setupResponseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = setupResponse.body;
|
||||
const responseTeamEvent = setupResponseBody.data.find((event) => event.teamId === team.id);
|
||||
if (responseTeamEvent) {
|
||||
managedEventType = responseTeamEvent;
|
||||
}
|
||||
}
|
||||
return managedEventType;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -396,29 +438,36 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teamMember2.id);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
|
||||
expect(teammate1EventTypes.length).toEqual(1);
|
||||
expect(teammate1EventTypes[0].title).toEqual(body.title);
|
||||
expect(teammate2EventTypes.length).toEqual(1);
|
||||
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
|
||||
1
|
||||
const teammate1ManagedEvents = teammate1EventTypes.filter((et) => et.title === body.title);
|
||||
const teammate2ManagedEvents = teammate2EventTypes.filter((et) => et.title === body.title);
|
||||
const managedTeamEvents = teamEventTypes.filter(
|
||||
(eventType) => eventType.schedulingType === "MANAGED" && eventType.title === body.title
|
||||
);
|
||||
|
||||
expect(teammate1ManagedEvents.length).toEqual(1);
|
||||
expect(teammate1ManagedEvents[0].title).toEqual(body.title);
|
||||
expect(teammate2ManagedEvents.length).toEqual(1);
|
||||
expect(managedTeamEvents.length).toEqual(1);
|
||||
|
||||
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
|
||||
expect(responseTeamEvent).toBeDefined();
|
||||
expect(responseTeamEvent?.hosts).toEqual([
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
name: teamMember1.name,
|
||||
username: teamMember1.username,
|
||||
avatarUrl: teamMember1.avatarUrl,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
name: teamMember2.name,
|
||||
username: teamMember2.username,
|
||||
avatarUrl: teamMember2.avatarUrl,
|
||||
},
|
||||
]);
|
||||
expect(responseTeamEvent?.hosts).toHaveLength(2);
|
||||
expect(responseTeamEvent?.hosts).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
name: teamMember1.name,
|
||||
username: teamMember1.username,
|
||||
avatarUrl: teamMember1.avatarUrl,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
name: teamMember2.name,
|
||||
username: teamMember2.username,
|
||||
avatarUrl: teamMember2.avatarUrl,
|
||||
},
|
||||
])
|
||||
);
|
||||
|
||||
if (!responseTeamEvent) {
|
||||
throw new Error("Team event not found");
|
||||
@@ -437,6 +486,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("managed team event types should be returned when fetching event types of users", async () => {
|
||||
await ensureManagedEventType();
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types?username=${teamMember1.username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
@@ -446,16 +497,19 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].slug).toEqual(managedEventType.slug);
|
||||
expect(data[0].ownerId).toEqual(teamMember1.id);
|
||||
expect(data[0].id).not.toEqual(managedEventType.id);
|
||||
const managedEvents = data.filter((event) => event.slug === managedEventType.slug);
|
||||
expect(managedEvents.length).toEqual(1);
|
||||
expect(managedEvents[0].slug).toEqual(managedEventType.slug);
|
||||
expect(managedEvents[0].ownerId).toEqual(teamMember1.id);
|
||||
expect(managedEvents[0].id).not.toEqual(managedEventType.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("managed team event type should be returned when fetching event types of users", async () => {
|
||||
await ensureManagedEventType();
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType.slug}`)
|
||||
.get(`/v2/event-types?username=${teamMember1.username}&eventSlug=${managedEventType?.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -463,10 +517,11 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const data = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(data[0].slug).toEqual(managedEventType.slug);
|
||||
expect(data[0].ownerId).toEqual(teamMember1.id);
|
||||
expect(data[0].id).not.toEqual(managedEventType.id);
|
||||
const managedEventTypes = data.filter((et) => et.slug === managedEventType?.slug);
|
||||
expect(managedEventTypes.length).toEqual(1);
|
||||
expect(managedEventTypes[0].slug).toEqual(managedEventType?.slug);
|
||||
expect(managedEventTypes[0].ownerId).toEqual(teamMember1.id);
|
||||
expect(managedEventTypes[0].id).not.toEqual(managedEventType?.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -570,6 +625,8 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should update managed event-type", async () => {
|
||||
await ensureManagedEventType();
|
||||
|
||||
const newTitle = `teams-event-types-managed-updated-${randomString()}`;
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
@@ -585,7 +642,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.patch(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -629,12 +686,14 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should assign all members to managed event-type", async () => {
|
||||
await ensureManagedEventType();
|
||||
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
assignAllTeamMembers: true,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.patch(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
@@ -693,8 +752,10 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
});
|
||||
|
||||
it("should delete managed event-type", async () => {
|
||||
await ensureManagedEventType();
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/teams/${team.id}/event-types/${managedEventType.id}`)
|
||||
.delete(`/v2/teams/${team.id}/event-types/${managedEventType?.id}`)
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
|
||||
@@ -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." })
|
||||
|
||||
+1706
-174
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",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
"use server";
|
||||
|
||||
import { revalidateTag } from "next/cache";
|
||||
|
||||
// Coupled with `getCachedTeamData` in `queries.ts`
|
||||
export async function revalidateTeamDataCache({
|
||||
teamSlug,
|
||||
orgSlug,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
orgSlug: string | null;
|
||||
}) {
|
||||
revalidateTag(`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`);
|
||||
}
|
||||
|
||||
// Coupled with `getCachedTeamEventType` in `queries.ts`
|
||||
export async function revalidateTeamEventTypeCache({
|
||||
teamSlug,
|
||||
meetingSlug,
|
||||
orgSlug,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
meetingSlug: string;
|
||||
orgSlug: string | null;
|
||||
}) {
|
||||
revalidateTag(`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import type { PageProps, SearchParams } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
|
||||
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
|
||||
@@ -13,7 +14,20 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
import LegacyPage from "~/team/type-view";
|
||||
import type { PageProps as LegacyPageProps } from "~/team/type-view";
|
||||
|
||||
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
||||
|
||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
"team-booking-page-cache"
|
||||
);
|
||||
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
|
||||
}
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
||||
return await generateCachedMetadata({ params, searchParams });
|
||||
}
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
|
||||
@@ -53,6 +67,10 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
||||
return await CachedTeamBooker({ params, searchParams });
|
||||
}
|
||||
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
@@ -70,4 +88,5 @@ const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
|
||||
return <LegacyPage {...props} />;
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import type { PageProps, Params } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { headers, cookies } from "next/headers";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, type GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getOrgFullOrigin, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import type { TeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
||||
import { shouldHideBrandingForTeamEvent } from "@calcom/lib/hideBranding";
|
||||
import { loadTranslations } from "@calcom/lib/server/i18n";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { BookingStatus, RedirectType } from "@calcom/prisma/enums";
|
||||
|
||||
import { buildLegacyCtx, buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
|
||||
import CachedClientView, { type TeamBookingPageProps } from "~/team/type-view-cached";
|
||||
|
||||
import { getCachedTeamData, getEnrichedEventType, getCRMData, shouldUseApiV2ForTeamSlots } from "./queries";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId: number) => {
|
||||
const organizationSettings = getOrganizationSEOSettings(teamData);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
return {
|
||||
orgBannerUrl: teamData.parent?.bannerUrl ?? "",
|
||||
hideBranding: shouldHideBrandingForTeamEvent({
|
||||
eventTypeId,
|
||||
team: teamData,
|
||||
}),
|
||||
isSEOIndexable: allowSEOIndexing,
|
||||
};
|
||||
};
|
||||
|
||||
async function _getOrgContext(params: Params) {
|
||||
const result = paramsSchema.safeParse({
|
||||
slug: params?.slug,
|
||||
type: params?.type,
|
||||
});
|
||||
|
||||
if (!result.success) return notFound(); // should never happen
|
||||
|
||||
const { slug: teamSlug, type: meetingSlug } = result.data;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
buildLegacyRequest(await headers(), await cookies()),
|
||||
params?.orgSlug ?? undefined
|
||||
);
|
||||
|
||||
return {
|
||||
currentOrgDomain,
|
||||
isValidOrgDomain,
|
||||
teamSlug,
|
||||
meetingSlug,
|
||||
};
|
||||
}
|
||||
|
||||
const _getMultipleDurationValue = (
|
||||
multipleDurationConfig: number[] | undefined,
|
||||
queryDuration: string | string[] | null | undefined,
|
||||
defaultValue: number
|
||||
) => {
|
||||
if (!multipleDurationConfig) return null;
|
||||
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
|
||||
return defaultValue;
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
|
||||
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||
if (!teamData) return {}; // should never happen
|
||||
|
||||
const enrichedEventType = await getEnrichedEventType({
|
||||
teamSlug,
|
||||
meetingSlug,
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
|
||||
fromRedirectOfNonOrgLink: (await searchParams).orgRedirection === "true",
|
||||
});
|
||||
if (!enrichedEventType) return {}; // should never happen
|
||||
|
||||
const title = enrichedEventType.title;
|
||||
const profileName = enrichedEventType.profile.name ?? "";
|
||||
const profileImage = enrichedEventType.profile.image;
|
||||
|
||||
const meeting = {
|
||||
title,
|
||||
profile: { name: profileName, image: profileImage },
|
||||
users: [
|
||||
...(enrichedEventType?.subsetOfUsers || []).map((user) => ({
|
||||
name: `${user.name}`,
|
||||
username: `${user.username}`,
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
const { hideBranding, isSEOIndexable } = _getTeamMetadataForBooking(teamData, enrichedEventType.id);
|
||||
|
||||
const metadata = await generateMeetingMetadata(
|
||||
meeting,
|
||||
() => `${title} | ${profileName}`,
|
||||
() => title,
|
||||
hideBranding,
|
||||
getOrgFullOrigin(enrichedEventType.entity.orgSlug ?? null),
|
||||
`/team/${teamSlug}/${meetingSlug}`
|
||||
);
|
||||
|
||||
return {
|
||||
...metadata,
|
||||
robots: {
|
||||
follow: !(enrichedEventType.hidden || !isSEOIndexable),
|
||||
index: !(enrichedEventType.hidden || !isSEOIndexable),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
// Handle org redirects for non-org contexts
|
||||
if (!isOrgContext) {
|
||||
const redirectResult = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: legacyCtx.query,
|
||||
});
|
||||
if (redirectResult) return redirect(redirectResult.redirect.destination);
|
||||
}
|
||||
|
||||
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||
|
||||
if (!teamData) return notFound();
|
||||
|
||||
const enrichedEventType = await getEnrichedEventType({
|
||||
teamSlug,
|
||||
meetingSlug,
|
||||
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
|
||||
fromRedirectOfNonOrgLink: legacyCtx.query.orgRedirection === "true",
|
||||
});
|
||||
if (!enrichedEventType) return notFound();
|
||||
|
||||
// Handle rescheduling
|
||||
const { rescheduleUid } = legacyCtx.query;
|
||||
let bookingForReschedule: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
const session = await getServerSession({ req: legacyCtx.req });
|
||||
bookingForReschedule = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (enrichedEventType.disableRescheduling) return redirect(`/booking/${rescheduleUid}`);
|
||||
|
||||
if (
|
||||
bookingForReschedule?.status === BookingStatus.CANCELLED &&
|
||||
legacyCtx.query.allowRescheduleForCancelledBooking !== "true" &&
|
||||
!enrichedEventType.allowReschedulingCancelledBookings
|
||||
) {
|
||||
// redirecting to the same booking page without `rescheduleUid` search param
|
||||
return redirect(`/team/${teamSlug}/${meetingSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const [crmData, useApiV2] = await Promise.all([
|
||||
getCRMData(legacyCtx.query, {
|
||||
id: enrichedEventType.id,
|
||||
isInstantEvent: enrichedEventType.isInstantEvent,
|
||||
schedulingType: enrichedEventType.schedulingType,
|
||||
metadata: enrichedEventType.metadata,
|
||||
length: enrichedEventType.length,
|
||||
}),
|
||||
shouldUseApiV2ForTeamSlots(teamData.id),
|
||||
]);
|
||||
|
||||
const props: TeamBookingPageProps = {
|
||||
..._getTeamMetadataForBooking(teamData, enrichedEventType.id),
|
||||
...crmData,
|
||||
useApiV2,
|
||||
isInstantMeeting: legacyCtx.query.isInstantMeeting === "true",
|
||||
eventSlug: meetingSlug,
|
||||
username: teamSlug,
|
||||
eventData: {
|
||||
...enrichedEventType,
|
||||
},
|
||||
entity: { ...enrichedEventType.entity },
|
||||
bookingData: bookingForReschedule,
|
||||
isTeamEvent: true,
|
||||
durationConfig: enrichedEventType.metadata?.multipleDuration,
|
||||
duration: _getMultipleDurationValue(
|
||||
enrichedEventType.metadata?.multipleDuration,
|
||||
legacyCtx.query.duration,
|
||||
enrichedEventType.length
|
||||
),
|
||||
};
|
||||
const Booker = <CachedClientView {...props} />;
|
||||
|
||||
const eventLocale = enrichedEventType.interfaceLanguage;
|
||||
if (eventLocale) {
|
||||
const ns = "common";
|
||||
const translations = await loadTranslations(eventLocale, ns);
|
||||
return (
|
||||
<CustomI18nProvider translations={translations} locale={eventLocale} ns={ns}>
|
||||
{Booker}
|
||||
</CustomI18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return Booker;
|
||||
};
|
||||
|
||||
export default CachedTeamBooker;
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { getTeamData } from "@calcom/features/ee/teams/lib/getTeamData";
|
||||
import {
|
||||
getEventTypeHosts,
|
||||
getProfileFromEvent,
|
||||
getUsersFromEvent,
|
||||
processEventDataShared,
|
||||
} from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventType";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export async function getCachedTeamData(teamSlug: string, orgSlug: string | null) {
|
||||
return unstable_cache(async () => getTeamData(teamSlug, orgSlug), ["team-data", teamSlug, orgSlug ?? ""], {
|
||||
revalidate: NEXTJS_CACHE_TTL,
|
||||
tags: [`team:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}`],
|
||||
})();
|
||||
}
|
||||
|
||||
export async function getCachedTeamEventType(teamSlug: string, meetingSlug: string, orgSlug: string | null) {
|
||||
return unstable_cache(
|
||||
async () => getTeamEventType(teamSlug, meetingSlug, orgSlug),
|
||||
["team-event-type", teamSlug, meetingSlug, orgSlug ?? ""],
|
||||
{
|
||||
revalidate: NEXTJS_CACHE_TTL,
|
||||
tags: [`event-type:${orgSlug ? `${orgSlug}:` : ""}${teamSlug}:${meetingSlug}`],
|
||||
}
|
||||
)();
|
||||
}
|
||||
|
||||
export async function getEnrichedEventType({
|
||||
teamSlug,
|
||||
meetingSlug,
|
||||
orgSlug,
|
||||
fromRedirectOfNonOrgLink,
|
||||
}: {
|
||||
teamSlug: string;
|
||||
meetingSlug: string;
|
||||
orgSlug: string | null;
|
||||
fromRedirectOfNonOrgLink: boolean;
|
||||
}) {
|
||||
const [teamData, eventType] = await Promise.all([
|
||||
getCachedTeamData(teamSlug, orgSlug),
|
||||
getCachedTeamEventType(teamSlug, meetingSlug, orgSlug),
|
||||
]);
|
||||
|
||||
if (!teamData || !eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { subsetOfHosts, hosts } = await getEventTypeHosts({
|
||||
hosts: eventType.hosts,
|
||||
prisma,
|
||||
});
|
||||
|
||||
const enrichedOwner = eventType.owner
|
||||
? await new UserRepository(prisma).enrichUserWithItsProfile({
|
||||
user: eventType.owner,
|
||||
})
|
||||
: null;
|
||||
const users =
|
||||
(await getUsersFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }, prisma)) ?? [];
|
||||
const name = teamData.parent?.name ?? teamData.name ?? null;
|
||||
const isUnpublished = teamData.parent ? !teamData.parent.slug : !teamData.slug;
|
||||
|
||||
const eventMetaData = eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata);
|
||||
|
||||
const eventDataShared = await processEventDataShared({
|
||||
eventData: eventType,
|
||||
metadata: eventMetaData,
|
||||
prisma,
|
||||
});
|
||||
|
||||
return {
|
||||
...eventDataShared,
|
||||
owner: enrichedOwner,
|
||||
subsetOfHosts,
|
||||
hosts,
|
||||
profile: getProfileFromEvent({ ...eventType, owner: enrichedOwner, subsetOfHosts, hosts }),
|
||||
subsetOfUsers: users,
|
||||
users,
|
||||
entity: {
|
||||
fromRedirectOfNonOrgLink,
|
||||
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
|
||||
orgSlug,
|
||||
teamSlug: teamData.slug ?? null,
|
||||
name,
|
||||
hideProfileLink: false,
|
||||
logoUrl: teamData.parent
|
||||
? getPlaceholderAvatar(teamData.parent.logoUrl, teamData.parent.name)
|
||||
: getPlaceholderAvatar(teamData.logoUrl, teamData.name),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
|
||||
|
||||
return useApiV2;
|
||||
}
|
||||
|
||||
export async function getCRMData(
|
||||
query: GetServerSidePropsContext["query"],
|
||||
eventData: {
|
||||
id: number;
|
||||
isInstantEvent: boolean;
|
||||
schedulingType: SchedulingType | null;
|
||||
metadata: Prisma.JsonValue | null;
|
||||
length: number;
|
||||
}
|
||||
) {
|
||||
const crmContactOwnerEmail = query["cal.crmContactOwnerEmail"];
|
||||
const crmContactOwnerRecordType = query["cal.crmContactOwnerRecordType"];
|
||||
const crmAppSlugParam = query["cal.crmAppSlug"];
|
||||
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||
? crmContactOwnerRecordType[0]
|
||||
: crmContactOwnerRecordType;
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
|
||||
"@calcom/lib/server/getTeamMemberEmailFromCrm"
|
||||
);
|
||||
const {
|
||||
email,
|
||||
recordType,
|
||||
crmAppSlug: crmAppSlugQuery,
|
||||
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
|
||||
query,
|
||||
eventData,
|
||||
});
|
||||
|
||||
teamMemberEmail = email ?? undefined;
|
||||
crmOwnerRecordType = recordType ?? undefined;
|
||||
crmAppSlug = crmAppSlugQuery ?? undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
teamMemberEmail,
|
||||
crmOwnerRecordType,
|
||||
crmAppSlug,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createRouterCaller } from "app/_trpc/context";
|
||||
import type { SearchParams } from "app/_types";
|
||||
import type { Session } from "next-auth";
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { TeamsListing } from "@calcom/features/ee/teams/components/TeamsListing";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { TeamService } from "@calcom/lib/server/service/team";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { meRouter } from "@calcom/trpc/server/routers/viewer/me/_router";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -47,18 +46,24 @@ export const ServerTeamsListing = async ({
|
||||
}
|
||||
}
|
||||
|
||||
const meCaller = await createRouterCaller(meRouter);
|
||||
const [user, teams] = await Promise.all([meCaller.get(), getCachedTeams(userId)]);
|
||||
const teams = await getCachedTeams(userId);
|
||||
const userProfile = session?.user?.profile;
|
||||
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
return {
|
||||
Main: (
|
||||
<TeamsListing
|
||||
teams={teams}
|
||||
user={user}
|
||||
orgId={orgId ?? null}
|
||||
isOrgAdmin={isOrgAdminOrOwner}
|
||||
teamNameFromInvite={teamNameFromInvite ?? null}
|
||||
errorMsgFromInvite={errorMsgFromInvite}
|
||||
/>
|
||||
),
|
||||
CTA: !user.organizationId || user.organization.isOrgAdmin ? <TeamsCTA /> : null,
|
||||
CTA: !orgId || isOrgAdminOrOwner ? <TeamsCTA /> : null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
export default function PlaygroundLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
|
||||
const isPlaygroundRoot = pathname === "/settings/admin/playground";
|
||||
|
||||
return isPlaygroundRoot ? (
|
||||
children
|
||||
) : (
|
||||
<div>
|
||||
<Link href="/settings/admin/playground" className="text-sm underline">
|
||||
← Playground
|
||||
</Link>
|
||||
<div className="h-8" />
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const LINKS = [
|
||||
{
|
||||
title: "Routing Funnel",
|
||||
href: "/settings/admin/playground/routing-funnel",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Playground</h1>
|
||||
|
||||
<ul className="mt-8">
|
||||
{LINKS.map((link) => (
|
||||
<li key={link.title}>
|
||||
<Link href={link.href} className="list-item list-disc font-medium underline">
|
||||
{link.title} →
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import { ChartCard } from "@calcom/features/insights/components/ChartCard";
|
||||
import { RoutingFunnelContent, legend } from "@calcom/features/insights/components/RoutingFunnelContent";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
// Random sample data for playground testing
|
||||
const sampleRoutingFunnelData = [
|
||||
{
|
||||
name: "Week 1",
|
||||
formattedDateFull: "Week 1",
|
||||
totalSubmissions: 150,
|
||||
successfulRoutings: 120,
|
||||
acceptedBookings: 95,
|
||||
},
|
||||
{
|
||||
name: "Week 2",
|
||||
formattedDateFull: "Week 2",
|
||||
totalSubmissions: 180,
|
||||
successfulRoutings: 145,
|
||||
acceptedBookings: 110,
|
||||
},
|
||||
{
|
||||
name: "Week 3",
|
||||
formattedDateFull: "Week 3",
|
||||
totalSubmissions: 200,
|
||||
successfulRoutings: 160,
|
||||
acceptedBookings: 125,
|
||||
},
|
||||
{
|
||||
name: "Week 4",
|
||||
formattedDateFull: "Week 4",
|
||||
totalSubmissions: 170,
|
||||
successfulRoutings: 135,
|
||||
acceptedBookings: 105,
|
||||
},
|
||||
{
|
||||
name: "Week 5",
|
||||
formattedDateFull: "Week 5",
|
||||
totalSubmissions: 220,
|
||||
successfulRoutings: 175,
|
||||
acceptedBookings: 140,
|
||||
},
|
||||
{
|
||||
name: "Week 6",
|
||||
formattedDateFull: "Week 6",
|
||||
totalSubmissions: 190,
|
||||
successfulRoutings: 155,
|
||||
acceptedBookings: 120,
|
||||
},
|
||||
];
|
||||
|
||||
export default function RoutingFunnelPlayground() {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold">Routing Funnel Playground</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
This page demonstrates the RoutingFunnelContent component with sample data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl">
|
||||
<ChartCard
|
||||
title={t("routing_funnel")}
|
||||
subtitle="Hello world!"
|
||||
legend={legend}
|
||||
legendSize="sm"
|
||||
cta={{
|
||||
label: "Show all",
|
||||
onClick: () => {
|
||||
alert("hello!");
|
||||
},
|
||||
}}>
|
||||
<RoutingFunnelContent data={sampleRoutingFunnelData} />
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 rounded-lg bg-gray-50 p-4">
|
||||
<h2 className="mb-2 text-lg font-semibold">Sample Data Used:</h2>
|
||||
<pre className="overflow-auto text-sm text-gray-700">
|
||||
{JSON.stringify(sampleRoutingFunnelData, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+3
-7
@@ -92,7 +92,8 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
? [
|
||||
{
|
||||
name: "members",
|
||||
href: `/settings/organizations/${orgBranding.slug}/members`,
|
||||
href: `${WEBAPP_URL}/settings/organizations/i/members`,
|
||||
isExternalLink: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
@@ -146,6 +147,7 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
|
||||
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
|
||||
{ name: "oAuth", href: "/settings/admin/oAuth" },
|
||||
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
|
||||
{ name: "Playground", href: "/settings/admin/playground" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -400,12 +402,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?
|
||||
|
||||
+6
-1
@@ -8,7 +8,12 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
|
||||
@@ -8,7 +8,12 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
const OrgAdminOnlyLayout = async ({ children }: { children: React.ReactNode }) => {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(session?.user?.org?.role);
|
||||
const userProfile = session?.user?.profile;
|
||||
const userId = session?.user?.id;
|
||||
const orgRole =
|
||||
session?.user?.org?.role ??
|
||||
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
|
||||
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
|
||||
|
||||
if (!isOrgAdminOrOwner) {
|
||||
return redirect("/settings/organizations/profile");
|
||||
|
||||
@@ -21,15 +21,17 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { isOpenDialog, setIsOpenDialog, bookingId } = props;
|
||||
const [chargeError, setChargeError] = useState(false);
|
||||
const [chargeError, setChargeError] = useState<string | null>(null);
|
||||
|
||||
const chargeCardMutation = trpc.viewer.payments.chargeCard.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.bookings.invalidate();
|
||||
setIsOpenDialog(false);
|
||||
setChargeError(null);
|
||||
showToast("Charge successful", "success");
|
||||
},
|
||||
onError: () => {
|
||||
setChargeError(true);
|
||||
onError: (error) => {
|
||||
setChargeError(error.message || t("error_charging_card"));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -52,7 +54,7 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
{chargeError && (
|
||||
<div className="mt-4 flex text-red-500">
|
||||
<Icon name="triangle-alert" className="mr-2 h-5 w-5 " aria-hidden="true" />
|
||||
<p className="text-sm">{t("error_charging_card")}</p>
|
||||
<p className="text-sm">{chargeError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -60,12 +62,13 @@ export const ChargeCardDialog = (props: IRescheduleDialog) => {
|
||||
<DialogClose />
|
||||
<Button
|
||||
data-testid="send_request"
|
||||
disabled={chargeCardMutation.isPending || chargeError}
|
||||
onClick={() =>
|
||||
disabled={chargeCardMutation.isPending}
|
||||
onClick={() => {
|
||||
setChargeError(null);
|
||||
chargeCardMutation.mutate({
|
||||
bookingId,
|
||||
})
|
||||
}>
|
||||
});
|
||||
}}>
|
||||
{t("charge_attendee", currencyStringParams)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -23,6 +23,7 @@ type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
enableAutomaticRecordingForOrganizer: boolean;
|
||||
disableTranscriptionForGuests: boolean;
|
||||
disableTranscriptionForOrganizer: boolean;
|
||||
};
|
||||
@@ -59,6 +60,21 @@ const shouldEnableAutomaticTranscription = ({
|
||||
return !!calVideoSettings.enableAutomaticTranscription;
|
||||
};
|
||||
|
||||
const shouldEnableAutomaticRecording = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
isOrganizer,
|
||||
}: {
|
||||
hasTeamPlan: boolean;
|
||||
calVideoSettings?: CalVideoSettings | null;
|
||||
isOrganizer: boolean;
|
||||
}) => {
|
||||
if (!hasTeamPlan || !isOrganizer) return false;
|
||||
if (!calVideoSettings) return false;
|
||||
|
||||
return !!calVideoSettings.enableAutomaticRecordingForOrganizer;
|
||||
};
|
||||
|
||||
const shouldEnableTranscriptionButton = ({
|
||||
hasTeamPlan,
|
||||
calVideoSettings,
|
||||
@@ -239,17 +255,24 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
const showRecordingButton = shouldEnableRecordButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
isOrganizer,
|
||||
});
|
||||
|
||||
const enableAutomaticTranscription = shouldEnableAutomaticTranscription({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
});
|
||||
|
||||
const enableAutomaticRecordingForOrganizer = shouldEnableAutomaticRecording({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer,
|
||||
});
|
||||
|
||||
const showTranscriptionButton = shouldEnableTranscriptionButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
isOrganizer,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -274,6 +297,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
enableAutomaticRecordingForOrganizer,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
|
||||
@@ -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">) => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
RoutingFormResponsesTable,
|
||||
FailedBookingsByField,
|
||||
RoutedToPerPeriod,
|
||||
RoutingFunnel,
|
||||
} from "@calcom/features/insights/components";
|
||||
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -19,9 +20,12 @@ export default function InsightsRoutingFormResponsesPage() {
|
||||
<div className="mb-4 space-y-4">
|
||||
<RoutingFormResponsesTable />
|
||||
|
||||
<RoutedToPerPeriod />
|
||||
<RoutingFunnel />
|
||||
|
||||
<FailedBookingsByField />
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
<RoutedToPerPeriod />
|
||||
<FailedBookingsByField />
|
||||
</div>
|
||||
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
|
||||
@@ -71,22 +71,22 @@ function InsightsPageContent() {
|
||||
|
||||
<BookingStatusLineChart />
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<PopularEventsTable />
|
||||
<AverageEventDurationChart />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<MostBookedTeamMembersTable />
|
||||
<LeastBookedTeamMembersTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MostCancelledBookingsTables />
|
||||
</div>
|
||||
<RecentFeedbackTable />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<HighestNoShowHostTable />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
|
||||
<HighestRatedMembersTable />
|
||||
<LowestRatedMembersTable />
|
||||
<div className="sm:col-span-2">
|
||||
<RecentFeedbackTable />
|
||||
</div>
|
||||
</div>
|
||||
<small className="text-default block text-center">
|
||||
{t("looking_for_more_insights")}{" "}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { BookerWebWrapper, type BookerWebWrapperAtomProps } from "@calcom/atoms/booker";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
|
||||
import BookingPageErrorBoundary from "@components/error/BookingPageErrorBoundary";
|
||||
|
||||
export type TeamBookingPageProps = Omit<BookerWebWrapperAtomProps, "eventData"> &
|
||||
Required<Pick<BookerWebWrapperAtomProps, "eventData">> & { isEmbed?: boolean };
|
||||
|
||||
export default function TeamBookingPage(props: TeamBookingPageProps) {
|
||||
return (
|
||||
<BookingPageErrorBoundary>
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!props.isEmbed })}>
|
||||
<BookerWebWrapper {...props} />
|
||||
</main>
|
||||
</BookingPageErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
import type { DailyCall } from "@daily-co/daily-js";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { BUTTONS } from "../button-states";
|
||||
import { createCalVideoCallbacks } from "../cal-video-premium-features";
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
TRANSCRIPTION_STARTED_ICON: "/transcription-started-icon.svg",
|
||||
RECORDING_IN_PROGRESS_ICON: "/recording-in-progress-icon.svg",
|
||||
TRANSCRIPTION_STOPPED_ICON: "/transcription-stopped-icon.svg",
|
||||
RECORDING_DEFAULT_ICON: "/recording-default-icon.svg",
|
||||
}));
|
||||
|
||||
const mockDaily: DailyCall = {
|
||||
startRecording: vi.fn(),
|
||||
stopRecording: vi.fn(),
|
||||
startTranscription: vi.fn(),
|
||||
stopTranscription: vi.fn(),
|
||||
updateCustomTrayButtons: vi.fn(),
|
||||
} as unknown as DailyCall;
|
||||
|
||||
const createMockRecording = (isRecording = false) => ({
|
||||
isRecording,
|
||||
});
|
||||
|
||||
const createMockTranscription = (isTranscribing = false) => ({
|
||||
isTranscribing,
|
||||
});
|
||||
|
||||
describe("CalVideoPremiumFeatures - End-to-End Callback Tests", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("onMeetingJoined", () => {
|
||||
it("should start transcription automatically when enabled and not already transcribing", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(false), // not transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: true,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onMeetingJoined();
|
||||
|
||||
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should start recording automatically when enabled and not already recording", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false), // not recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
});
|
||||
|
||||
callbacks.onMeetingJoined();
|
||||
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledWith({
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should start both transcription and recording when both are enabled", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false),
|
||||
transcription: createMockTranscription(false),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: true,
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
});
|
||||
|
||||
callbacks.onMeetingJoined();
|
||||
|
||||
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should not start transcription when already transcribing", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(true), // already transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: true,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onMeetingJoined();
|
||||
|
||||
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should not start recording when already recording", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(true), // already recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: true,
|
||||
});
|
||||
|
||||
callbacks.onMeetingJoined();
|
||||
|
||||
expect(mockDaily.startRecording).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCustomButtonClick - Recording Button", () => {
|
||||
it("should start recording when click occurs on Recording button and recording is stopped", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false), // not recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.onCustomButtonClick({ button_id: "recording" });
|
||||
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledWith({
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop recording when click occurs on Recording button and recording is started", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(true), // currently recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.onCustomButtonClick({ button_id: "recording" });
|
||||
|
||||
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onCustomButtonClick - Transcription Button", () => {
|
||||
it("should start transcription when click occurs on Transcription button and transcription is stopped", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(false), // not transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.onCustomButtonClick({ button_id: "transcription" });
|
||||
|
||||
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop transcription when click occurs on Transcription button and transcription is started", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(true), // currently transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.onCustomButtonClick({ button_id: "transcription" });
|
||||
|
||||
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onRecordingStarted", () => {
|
||||
it("should update custom tray buttons to show stop recording button", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onRecordingStarted();
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onRecordingStopped", () => {
|
||||
it("should update custom tray buttons to show start recording button", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onRecordingStopped();
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onTranscriptionStarted", () => {
|
||||
it("should update custom tray buttons to show stop transcription button", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onTranscriptionStarted();
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("onTranscriptionStopped", () => {
|
||||
it("should update custom tray buttons to show start transcription button", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.onTranscriptionStopped();
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleRecording", () => {
|
||||
it("should start recording when not currently recording", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false), // not recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.toggleRecording();
|
||||
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledWith({
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
expect(mockDaily.stopRecording).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop recording when currently recording", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(true), // currently recording
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.toggleRecording();
|
||||
|
||||
expect(mockDaily.stopRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // current state included
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleTranscription", () => {
|
||||
it("should start transcription when not currently transcribing", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(false), // not transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.toggleTranscription();
|
||||
|
||||
expect(mockDaily.startTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.stopTranscription).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
});
|
||||
|
||||
it("should stop transcription when currently transcribing", async () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(true), // currently transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
await callbacks.toggleTranscription();
|
||||
|
||||
expect(mockDaily.stopTranscription).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startTranscription).not.toHaveBeenCalled();
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.START_RECORDING, // current state included
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCustomTrayButtons", () => {
|
||||
it("should update both recording and transcription buttons when both are shown", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false),
|
||||
transcription: createMockTranscription(false),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("should update only recording button when transcription button is not shown", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false),
|
||||
transcription: createMockTranscription(false),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: false,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
});
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
});
|
||||
});
|
||||
|
||||
it("should update only transcription button when recording button is not shown", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(false),
|
||||
transcription: createMockTranscription(false),
|
||||
showRecordingButton: false,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.updateCustomTrayButtons({
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use current state when no override is provided", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(true), // currently recording
|
||||
transcription: createMockTranscription(false), // not transcribing
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.updateCustomTrayButtons({});
|
||||
|
||||
expect(mockDaily.updateCustomTrayButtons).toHaveBeenCalledWith({
|
||||
recording: BUTTONS.STOP_RECORDING, // because isRecording is true
|
||||
transcription: BUTTONS.START_TRANSCRIPTION, // because isTranscribing is false
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("startRecording", () => {
|
||||
it("should call daily.startRecording with correct parameters", () => {
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily: mockDaily,
|
||||
recording: createMockRecording(),
|
||||
transcription: createMockTranscription(),
|
||||
showRecordingButton: true,
|
||||
showTranscriptionButton: true,
|
||||
enableAutomaticTranscription: false,
|
||||
enableAutomaticRecordingForOrganizer: false,
|
||||
});
|
||||
|
||||
callbacks.startRecording();
|
||||
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledTimes(1);
|
||||
expect(mockDaily.startRecording).toHaveBeenCalledWith({
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
TRANSCRIPTION_STARTED_ICON,
|
||||
RECORDING_IN_PROGRESS_ICON,
|
||||
TRANSCRIPTION_STOPPED_ICON,
|
||||
RECORDING_DEFAULT_ICON,
|
||||
} from "@calcom/lib/constants";
|
||||
|
||||
export const BUTTONS = {
|
||||
STOP_TRANSCRIPTION: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop transcription",
|
||||
iconPath: TRANSCRIPTION_STARTED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
START_TRANSCRIPTION: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
START_RECORDING: {
|
||||
label: "Record",
|
||||
tooltip: "Start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
STOP_RECORDING: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop recording",
|
||||
iconPath: RECORDING_IN_PROGRESS_ICON,
|
||||
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
|
||||
},
|
||||
};
|
||||
+164
-168
@@ -1,65 +1,9 @@
|
||||
import type { DailyCall } from "@daily-co/daily-js";
|
||||
import { useTranscription, useRecording } from "@daily-co/daily-react";
|
||||
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
|
||||
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
|
||||
|
||||
import {
|
||||
TRANSCRIPTION_STARTED_ICON,
|
||||
RECORDING_IN_PROGRESS_ICON,
|
||||
TRANSCRIPTION_STOPPED_ICON,
|
||||
RECORDING_DEFAULT_ICON,
|
||||
} from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
const BUTTONS = {
|
||||
STOP_TRANSCRIPTION: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop transcription",
|
||||
iconPath: TRANSCRIPTION_STARTED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
|
||||
},
|
||||
START_TRANSCRIPTION: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
WAIT_FOR_TRANSCRIPTION_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop transcription",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
START_RECORDING: {
|
||||
label: "Record",
|
||||
tooltip: "Start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_START: {
|
||||
label: "Starting..",
|
||||
tooltip: "Please wait while we start recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
WAIT_FOR_RECORDING_TO_STOP: {
|
||||
label: "Stopping..",
|
||||
tooltip: "Please wait while we stop recording",
|
||||
iconPath: RECORDING_DEFAULT_ICON,
|
||||
iconPathDarkMode: RECORDING_DEFAULT_ICON,
|
||||
},
|
||||
STOP_RECORDING: {
|
||||
label: "Stop",
|
||||
tooltip: "Stop recording",
|
||||
iconPath: RECORDING_IN_PROGRESS_ICON,
|
||||
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
|
||||
},
|
||||
};
|
||||
import { BUTTONS } from "./button-states";
|
||||
|
||||
export type DailyCustomTrayButtonVisualState = "default" | "sidebar-open" | "active";
|
||||
|
||||
@@ -70,46 +14,180 @@ export interface DailyCustomTrayButton {
|
||||
tooltip: string;
|
||||
visualState?: DailyCustomTrayButtonVisualState;
|
||||
}
|
||||
export const CalAiTranscribe = ({
|
||||
|
||||
type RecordingState = {
|
||||
isRecording: boolean;
|
||||
};
|
||||
|
||||
type TranscriptionState = {
|
||||
isTranscribing: boolean;
|
||||
};
|
||||
|
||||
type CalVideoCallbacksParams = {
|
||||
daily: DailyCall | null;
|
||||
recording: RecordingState | null;
|
||||
transcription: TranscriptionState | null;
|
||||
showRecordingButton: boolean;
|
||||
showTranscriptionButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
enableAutomaticRecordingForOrganizer: boolean;
|
||||
};
|
||||
|
||||
export const createCalVideoCallbacks = (params: CalVideoCallbacksParams) => {
|
||||
const {
|
||||
daily,
|
||||
recording,
|
||||
transcription,
|
||||
showRecordingButton,
|
||||
showTranscriptionButton,
|
||||
enableAutomaticTranscription,
|
||||
enableAutomaticRecordingForOrganizer,
|
||||
} = params;
|
||||
|
||||
const startRecording = () => {
|
||||
daily?.startRecording({
|
||||
// 480p
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const updateCustomTrayButtons = ({
|
||||
recording: overrideRecording,
|
||||
transcription: overrideTranscription,
|
||||
}: {
|
||||
recording?: DailyCustomTrayButton;
|
||||
transcription?: DailyCustomTrayButton;
|
||||
}) => {
|
||||
const currentRecordingState = recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING;
|
||||
const currentTranscriptionState = transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION;
|
||||
|
||||
daily?.updateCustomTrayButtons({
|
||||
...(showRecordingButton
|
||||
? {
|
||||
recording: overrideRecording ?? currentRecordingState,
|
||||
}
|
||||
: {}),
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription: overrideTranscription ?? currentTranscriptionState,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
const onMeetingJoined = () => {
|
||||
if (enableAutomaticTranscription && !transcription?.isTranscribing) {
|
||||
daily?.startTranscription();
|
||||
}
|
||||
if (enableAutomaticRecordingForOrganizer && !recording?.isRecording) {
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const onRecordingStarted = () => {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
});
|
||||
};
|
||||
|
||||
const onRecordingStopped = () => {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
});
|
||||
};
|
||||
|
||||
const onTranscriptionStarted = () => {
|
||||
updateCustomTrayButtons({
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
};
|
||||
|
||||
const onTranscriptionStopped = () => {
|
||||
updateCustomTrayButtons({
|
||||
transcription: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording?.isRecording) {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
});
|
||||
daily?.stopRecording();
|
||||
} else {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
});
|
||||
startRecording();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTranscription = async () => {
|
||||
if (transcription?.isTranscribing) {
|
||||
updateCustomTrayButtons({
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
daily?.stopTranscription();
|
||||
} else {
|
||||
updateCustomTrayButtons({
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
daily?.startTranscription();
|
||||
}
|
||||
};
|
||||
|
||||
const onCustomButtonClick = async (ev: { button_id: string }) => {
|
||||
if (ev?.button_id === "recording") {
|
||||
toggleRecording();
|
||||
} else if (ev?.button_id === "transcription") {
|
||||
toggleTranscription();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onMeetingJoined,
|
||||
onRecordingStarted,
|
||||
onRecordingStopped,
|
||||
onTranscriptionStarted,
|
||||
onTranscriptionStopped,
|
||||
onCustomButtonClick,
|
||||
toggleRecording,
|
||||
toggleTranscription,
|
||||
updateCustomTrayButtons,
|
||||
startRecording,
|
||||
};
|
||||
};
|
||||
|
||||
export const CalVideoPremiumFeatures = ({
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
enableAutomaticRecordingForOrganizer,
|
||||
showTranscriptionButton,
|
||||
}: {
|
||||
showRecordingButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
enableAutomaticRecordingForOrganizer: boolean;
|
||||
showTranscriptionButton: boolean;
|
||||
}) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
|
||||
const [transcript, setTranscript] = useState("");
|
||||
|
||||
const [transcriptHeight, setTranscriptHeight] = useState(0);
|
||||
const transcriptRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const transcription = useTranscription();
|
||||
const recording = useRecording();
|
||||
|
||||
const updateCustomTrayButtons = ({
|
||||
const callbacks = createCalVideoCallbacks({
|
||||
daily,
|
||||
recording,
|
||||
transcription,
|
||||
}: {
|
||||
recording: DailyCustomTrayButton;
|
||||
transcription: DailyCustomTrayButton;
|
||||
}) => {
|
||||
daily?.updateCustomTrayButtons({
|
||||
...(showRecordingButton
|
||||
? {
|
||||
recording,
|
||||
}
|
||||
: {}),
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
showRecordingButton,
|
||||
showTranscriptionButton,
|
||||
enableAutomaticTranscription,
|
||||
enableAutomaticRecordingForOrganizer,
|
||||
});
|
||||
|
||||
useDailyEvent(
|
||||
"app-message",
|
||||
@@ -119,94 +197,12 @@ export const CalAiTranscribe = ({
|
||||
}, [])
|
||||
);
|
||||
|
||||
useDailyEvent("joined-meeting", (ev) => {
|
||||
if (enableAutomaticTranscription) {
|
||||
daily?.startTranscription();
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useDailyEvent("transcription-started", (ev) => {
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.STOP_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("recording-started", (ev) => {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.STOP_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("transcription-stopped", (ev) => {
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
useDailyEvent("recording-stopped", (ev) => {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.START_RECORDING,
|
||||
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
});
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (recording?.isRecording) {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_STOP,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
await daily?.stopRecording();
|
||||
} else {
|
||||
updateCustomTrayButtons({
|
||||
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
|
||||
transcription: transcription?.isTranscribing
|
||||
? BUTTONS.STOP_TRANSCRIPTION
|
||||
: BUTTONS.START_TRANSCRIPTION,
|
||||
});
|
||||
|
||||
await daily?.startRecording({
|
||||
// 480p
|
||||
videoBitrate: 2000,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleTranscription = async () => {
|
||||
if (transcription?.isTranscribing) {
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_STOP,
|
||||
});
|
||||
daily?.stopTranscription();
|
||||
} else {
|
||||
updateCustomTrayButtons({
|
||||
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
|
||||
transcription: BUTTONS.WAIT_FOR_TRANSCRIPTION_TO_START,
|
||||
});
|
||||
|
||||
daily?.startTranscription();
|
||||
}
|
||||
};
|
||||
|
||||
useDailyEvent("custom-button-click", async (ev) => {
|
||||
if (ev?.button_id === "recording") {
|
||||
toggleRecording();
|
||||
} else if (ev?.button_id === "transcription") {
|
||||
toggleTranscription();
|
||||
}
|
||||
});
|
||||
useDailyEvent("joined-meeting", callbacks.onMeetingJoined);
|
||||
useDailyEvent("transcription-started", callbacks.onTranscriptionStarted);
|
||||
useDailyEvent("recording-started", callbacks.onRecordingStarted);
|
||||
useDailyEvent("transcription-stopped", callbacks.onTranscriptionStopped);
|
||||
useDailyEvent("recording-stopped", callbacks.onRecordingStopped);
|
||||
useDailyEvent("custom-button-click", callbacks.onCustomButtonClick);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
@@ -21,7 +21,7 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
|
||||
|
||||
import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
|
||||
import { CalVideoPremiumFeatures } from "../cal-video-premium-features";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
|
||||
overrideName,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
enableAutomaticRecordingForOrganizer,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit,
|
||||
} = props;
|
||||
@@ -116,8 +117,9 @@ export default function JoinCall(props: PageProps) {
|
||||
<div
|
||||
className="mx-auto hidden sm:block"
|
||||
style={{ zIndex: 2, left: "30%", position: "absolute", bottom: 100, width: "auto" }}>
|
||||
<CalAiTranscribe
|
||||
<CalVideoPremiumFeatures
|
||||
showRecordingButton={showRecordingButton}
|
||||
enableAutomaticRecordingForOrganizer={enableAutomaticRecordingForOrganizer}
|
||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||
showTranscriptionButton={showTranscriptionButton}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.19",
|
||||
"version": "5.5.2",
|
||||
"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;
|
||||
}
|
||||
@@ -48,6 +48,7 @@
|
||||
"calvideo_settings_description": "Configure video meeting preferences including recording permissions for hosts and attendees.",
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"enable_automatic_recording": "Enable automatic recording after organizer joins the meeting",
|
||||
"video_options": "Video Options",
|
||||
"get_meeting_session_details": "Get Meeting Session Details",
|
||||
"meeting_session_details": "Meeting Session Details",
|
||||
@@ -75,6 +76,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",
|
||||
@@ -283,7 +285,7 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"confirmation": "Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
@@ -2165,12 +2167,14 @@
|
||||
"popular_events": "Popular Events",
|
||||
"no_event_types_found": "No event types found",
|
||||
"average_event_duration": "Average Event Duration",
|
||||
"most_booked_members": "Most Booked Members",
|
||||
"least_booked_members": "Least Booked Members",
|
||||
"most_booked_members": "Most Booked",
|
||||
"least_booked_members": "Least Booked",
|
||||
"events_created": "Events Created",
|
||||
"events_completed": "Events Completed",
|
||||
"events_cancelled": "Events Cancelled",
|
||||
"events_rescheduled": "Events Rescheduled",
|
||||
"events": "Events",
|
||||
"performance": "Performance",
|
||||
"from_last_period": "from last period",
|
||||
"from_to_date_period": "From: {{startDate}} To: {{endDate}}",
|
||||
"redirect_url_warning": "Adding a redirect will disable the success page. Make sure to mention \"Booking Confirmed\" on your custom success page.",
|
||||
@@ -2194,6 +2198,10 @@
|
||||
"error_charging_card": "Something went wrong charging the no-show fee. Please try again later.",
|
||||
"collect_no_show_fee": "Collect no-show fee",
|
||||
"no_show_fee_charged": "No-show fee charged",
|
||||
"your_card_was_declined": "Payment declined. Please try a different card or contact your bank for assistance.",
|
||||
"your_card_does_not_support_this_type_of_purchase": "This card type isn't supported for this purchase. Please use a different payment method.",
|
||||
"amount_must_convert_to_at_least": "Minimum payment amount is $0.50 USD. Please increase your payment amount.",
|
||||
"could_not_charge_card": "Could not charge card for payment.",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Routing Forms",
|
||||
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
|
||||
@@ -2714,10 +2722,12 @@
|
||||
"event_ratings": "Average Ratings",
|
||||
"event_no_show": "No-Show (Host)",
|
||||
"event_no_show_guest": "No-Show (Guest)",
|
||||
"recent_ratings": "Recent ratings",
|
||||
"recent_ratings": "Recent Ratings",
|
||||
"no_ratings": "No ratings submitted",
|
||||
"no_ratings_description": "Add a workflow with 'Rating' to collect ratings after meetings",
|
||||
"most_no_show_host": "Most No Show Members",
|
||||
"most_no_show_host": "Most No-Show",
|
||||
"highest_rated": "Highest Rated",
|
||||
"lowest_rated": "Lowest Rated",
|
||||
"highest_rated_members": "Members with highest rated meetings",
|
||||
"lowest_rated_members": "Members with lowest rated meetings",
|
||||
"csat_score": "CSAT Score",
|
||||
@@ -3137,7 +3147,7 @@
|
||||
"display": "Display",
|
||||
"could_not_find_slug_to_publish_org": "Could not find slug to publish the organization",
|
||||
"picklist": "Picklist",
|
||||
"most_cancelled_bookings": "Most Cancelled Bookings",
|
||||
"most_cancelled_bookings": "Most Cancelled",
|
||||
"interface_language": "Interface Language",
|
||||
"visitors_browser_language": "Visitor's browser language",
|
||||
"name_or_email": "Name or Email",
|
||||
@@ -3322,7 +3332,7 @@
|
||||
"pbac_desc_manage_team_insights": "Manage team insights",
|
||||
"read_permission_auto_enabled_tooltip": "Read permission is automatically enabled when creating, updating, or deleting a resource",
|
||||
"choose_restriction_schedule": "Add restriction schedule",
|
||||
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
|
||||
"restriction_schedule_description": "Limit availability of hosts to only display slots that fall within a specified schedule.",
|
||||
"use_booker_timezone": "Use booker's timezone",
|
||||
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||
@@ -3343,6 +3353,10 @@
|
||||
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||
"routing_funnel": "Routing Funnel",
|
||||
"routing_funnel_total_submissions": "Total Submissions",
|
||||
"routing_funnel_successful_routings": "Successful Routings",
|
||||
"routing_funnel_accepted_bookings": "Accepted Bookings",
|
||||
"license_key": "License key",
|
||||
"license_selection_title": "Choose your license",
|
||||
"license_selection_description": "Select how you want to license your Cal.com deployment",
|
||||
@@ -3361,5 +3375,7 @@
|
||||
"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}})",
|
||||
"failed_bookings_by_field": "Failed Bookings By Field",
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
+4902
-928
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}`;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ const WriteToObjectSettings = ({
|
||||
checkboxFieldValueOptions[0]
|
||||
);
|
||||
const [whenToWriteSelectedOption, setWhenToWriteSelectedOption] = useState(whenToWriteToRecordOptions[0]);
|
||||
const [editingRows, setEditingRows] = useState<Record<string, boolean>>({});
|
||||
const [editingData, setEditingData] = useState<Record<string, z.infer<typeof writeToRecordEntrySchema>>>(
|
||||
{}
|
||||
);
|
||||
const [newOnWriteToRecordEntry, setNewOnWriteToRecordEntry] = useState<
|
||||
z.infer<typeof writeToRecordEntrySchema>
|
||||
>({
|
||||
@@ -80,6 +84,63 @@ const WriteToObjectSettings = ({
|
||||
whenToWrite: whenToWriteSelectedOption.value,
|
||||
});
|
||||
|
||||
const startEditing = (key: string) => {
|
||||
Object.keys(editingRows).forEach((rowKey) => {
|
||||
if (editingRows[rowKey] && rowKey !== key) {
|
||||
cancelEditing(rowKey);
|
||||
}
|
||||
});
|
||||
setEditingRows((prev) => ({ ...prev, [key]: true }));
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
field: key,
|
||||
fieldType: writeToObjectData[key].fieldType,
|
||||
value: writeToObjectData[key].value,
|
||||
whenToWrite: writeToObjectData[key].whenToWrite,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const cancelEditing = (key: string) => {
|
||||
setEditingRows((prev) => ({ ...prev, [key]: false }));
|
||||
setEditingData((prev) => {
|
||||
const newData = { ...prev };
|
||||
delete newData[key];
|
||||
return newData;
|
||||
});
|
||||
};
|
||||
|
||||
const saveEditing = (key: string) => {
|
||||
const editData = editingData[key];
|
||||
if (!editData) return;
|
||||
|
||||
if (!editData.field.trim()) {
|
||||
showToast("Field name cannot be empty", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
if (editData.field !== key && Object.keys(writeToObjectData).includes(editData.field.trim())) {
|
||||
showToast("Field already exists", "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const newWriteToObjectData = { ...writeToObjectData };
|
||||
|
||||
if (editData.field !== key) {
|
||||
delete newWriteToObjectData[key];
|
||||
}
|
||||
|
||||
newWriteToObjectData[editData.field.trim()] = {
|
||||
fieldType: editData.fieldType,
|
||||
value: editData.value,
|
||||
whenToWrite: editData.whenToWrite,
|
||||
};
|
||||
|
||||
updateWriteToObjectData(newWriteToObjectData);
|
||||
cancelEditing(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section.SubSectionHeader icon="star" labelFor="write-to-object-settings" title={optionLabel}>
|
||||
@@ -98,77 +159,213 @@ const WriteToObjectSettings = ({
|
||||
<div className="flex-1">{t("field_type")}</div>
|
||||
<div className="flex-1">{t("value")}</div>
|
||||
<div className="flex-1">{t("when_to_write")}</div>
|
||||
<div className="w-10" />
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
<Section.SubSectionNested>
|
||||
{Object.keys(writeToObjectData).map((key) => (
|
||||
<div className="flex items-center gap-2" key={key}>
|
||||
<div className="flex-1">
|
||||
<InputField value={key} readOnly size="sm" className="w-full" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={fieldTypeOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].fieldType
|
||||
{Object.keys(writeToObjectData).map((key) => {
|
||||
const isEditing = editingRows[key];
|
||||
const editData = editingData[key];
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2" key={key}>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<InputField
|
||||
value={editData?.field || key}
|
||||
onChange={(e) =>
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...editData, field: e.target.value },
|
||||
}))
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
<InputField value={key} readOnly size="sm" className="w-full" />
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{writeToObjectData[key].fieldType === SalesforceFieldType.DATE ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={dateFieldValueOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].value
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
) : writeToObjectData[key].fieldType === SalesforceFieldType.CHECKBOX ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={checkboxFieldValueOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].value
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
value={writeToObjectData[key].value as string}
|
||||
readOnly
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={whenToWriteToRecordOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].whenToWrite
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
options={fieldTypeOptions}
|
||||
value={fieldTypeOptions.find((option) => option.value === editData?.fieldType)}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: {
|
||||
...editData,
|
||||
fieldType: e.value,
|
||||
...(e.value === SalesforceFieldType.DATE && {
|
||||
value: dateFieldValueOptions[0].value,
|
||||
}),
|
||||
...(e.value === SalesforceFieldType.CHECKBOX && {
|
||||
value: checkboxFieldValueOptions[0].value,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={fieldTypeOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].fieldType
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
editData?.fieldType === SalesforceFieldType.DATE ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
options={dateFieldValueOptions}
|
||||
value={dateFieldValueOptions.find((option) => option.value === editData.value)}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...editData, value: e.value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : editData?.fieldType === SalesforceFieldType.CHECKBOX ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
options={checkboxFieldValueOptions}
|
||||
value={checkboxFieldValueOptions.find((option) => option.value === editData.value)}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...editData, value: e.value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
value={(editData?.value as string) || ""}
|
||||
onChange={(e) =>
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...editData, value: e.target.value },
|
||||
}))
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
)
|
||||
) : writeToObjectData[key].fieldType === SalesforceFieldType.DATE ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={dateFieldValueOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].value
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
) : writeToObjectData[key].fieldType === SalesforceFieldType.CHECKBOX ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={checkboxFieldValueOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].value
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
) : (
|
||||
<InputField
|
||||
value={writeToObjectData[key].value as string}
|
||||
readOnly
|
||||
size="sm"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isEditing ? (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
options={whenToWriteToRecordOptions}
|
||||
value={whenToWriteToRecordOptions.find(
|
||||
(option) => option.value === editData?.whenToWrite
|
||||
)}
|
||||
onChange={(e) => {
|
||||
if (e) {
|
||||
setEditingData((prev) => ({
|
||||
...prev,
|
||||
[key]: { ...editData, whenToWrite: e.value },
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
size="sm"
|
||||
className="w-full"
|
||||
value={whenToWriteToRecordOptions.find(
|
||||
(option) => option.value === writeToObjectData[key].whenToWrite
|
||||
)}
|
||||
isDisabled={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-20 justify-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
StartIcon="check"
|
||||
variant="icon"
|
||||
color="primary"
|
||||
onClick={() => saveEditing(key)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
StartIcon="x"
|
||||
variant="icon"
|
||||
color="secondary"
|
||||
onClick={() => cancelEditing(key)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
StartIcon="pencil"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
onClick={() => startEditing(key)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
StartIcon="x"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
const newObject = { ...writeToObjectData };
|
||||
delete newObject[key];
|
||||
updateWriteToObjectData(newObject);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-10 justify-center">
|
||||
<Button
|
||||
size="sm"
|
||||
StartIcon="x"
|
||||
variant="icon"
|
||||
color="minimal"
|
||||
onClick={() => {
|
||||
const newObject = writeToObjectData;
|
||||
delete writeToObjectData[key];
|
||||
updateWriteToObjectData(newObject);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1">
|
||||
<InputField
|
||||
@@ -268,7 +465,7 @@ const WriteToObjectSettings = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-10" />
|
||||
<div className="w-20" />
|
||||
</div>
|
||||
</Section.SubSectionNested>
|
||||
<Button
|
||||
|
||||
@@ -3,14 +3,16 @@ import jsforce from "@jsforce/jsforce-node";
|
||||
import { RRule } from "rrule";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
||||
import { getLocation } from "@calcom/lib/CalEventParser";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { RetryableError } from "@calcom/lib/crmManager/errors";
|
||||
import { checkIfFreeEmailDomain } from "@calcom/lib/freeEmailDomainCheck/checkIfFreeEmailDomain";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { PrismaRoutingFormResponseRepository as RoutingFormResponseRepository } from "@calcom/lib/server/repository/PrismaRoutingFormResponseRepository";
|
||||
import { AssignmentReasonRepository } from "@calcom/lib/server/repository/assignmentReason";
|
||||
import { RoutingFormResponseDataFactory } from "@calcom/lib/server/service/routingForm/RoutingFormResponseDataFactory";
|
||||
import { findFieldValueByIdentifier } from "@calcom/lib/server/service/routingForm/responseData/findFieldValueByIdentifier";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { CalendarEvent, CalEventResponses } from "@calcom/types/Calendar";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
@@ -433,7 +435,6 @@ export default class SalesforceCRMService implements CRM {
|
||||
accessToken: this.accessToken,
|
||||
instanceUrl: this.instanceUrl,
|
||||
});
|
||||
|
||||
return await client.GetAccountRecordsForRRSkip(emailArray[0]);
|
||||
} catch (error) {
|
||||
log.error("Error getting account records for round robin skip", safeStringify({ error }));
|
||||
@@ -1238,7 +1239,8 @@ export default class SalesforceCRMService implements CRM {
|
||||
log.error(`BookingUid not passed. Cannot get form responses without it`);
|
||||
return;
|
||||
}
|
||||
valueToWrite = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
|
||||
const formValue = await this.getTextValueFromRoutingFormResponse(fieldValue, bookingUid, recordId);
|
||||
valueToWrite = formValue || "";
|
||||
} else if (fieldValue.startsWith("{utm:")) {
|
||||
if (!bookingUid) {
|
||||
log.error(`BookingUid not passed. Cannot get tracking values without it`);
|
||||
@@ -1283,20 +1285,8 @@ export default class SalesforceCRMService implements CRM {
|
||||
prefix: [`[getTextValueFromRoutingFormResponse]: ${recordId} - bookingUid: ${bookingUid}`],
|
||||
});
|
||||
|
||||
// Get the form response
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
|
||||
where: {
|
||||
routedToBookingUid: bookingUid,
|
||||
},
|
||||
select: {
|
||||
response: true,
|
||||
},
|
||||
});
|
||||
if (!routingFormResponse) {
|
||||
log.error("Routing form response not found");
|
||||
return fieldValue;
|
||||
}
|
||||
const response = routingFormResponse.response as FormResponse;
|
||||
let value;
|
||||
|
||||
const regex = /\{form:(.*?)\}/;
|
||||
const regexMatch = fieldValue.match(regex);
|
||||
if (!regexMatch) {
|
||||
@@ -1310,19 +1300,23 @@ export default class SalesforceCRMService implements CRM {
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
// Search for fieldValue, only handle raw text return for now
|
||||
for (const fieldId of Object.keys(response)) {
|
||||
const field = response[fieldId];
|
||||
if (field?.identifier === identifierField) {
|
||||
return field.value.toString();
|
||||
}
|
||||
const routingFormResponseDataFactory = new RoutingFormResponseDataFactory({
|
||||
logger: log,
|
||||
routingFormResponseRepo: new RoutingFormResponseRepository(),
|
||||
});
|
||||
const findFieldResult = findFieldValueByIdentifier(
|
||||
await routingFormResponseDataFactory.createWithBookingUid(bookingUid),
|
||||
identifierField
|
||||
);
|
||||
if (findFieldResult.success) {
|
||||
value = findFieldResult.data;
|
||||
return String(value);
|
||||
}
|
||||
log.error(
|
||||
`Could not find form response value for identifierField ${identifierField} in response keys ${Object.keys(
|
||||
response
|
||||
)}`
|
||||
`Could not find field value for identifier ${identifierField} in bookingUid ${bookingUid}`,
|
||||
`failed with error: ${findFieldResult.error}`
|
||||
);
|
||||
|
||||
// If the field is not found, return the original field value
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import z from "zod";
|
||||
import { sendAwaitingPaymentEmailAndSMS } from "@calcom/emails";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -296,7 +297,28 @@ export class PaymentService implements IAbstractPaymentService {
|
||||
return paymentData;
|
||||
} catch (error) {
|
||||
log.error("Stripe: Could not charge card for payment", _bookingId, safeStringify(error));
|
||||
throw new Error(ErrorCode.ChargeCardFailure);
|
||||
|
||||
const errorMappings = {
|
||||
"your card was declined": "your_card_was_declined",
|
||||
"your card does not support this type of purchase":
|
||||
"your_card_does_not_support_this_type_of_purchase",
|
||||
"amount must convert to at least": "amount_must_convert_to_at_least",
|
||||
};
|
||||
|
||||
let userMessage = "could_not_charge_card";
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
|
||||
for (const [key, message] of Object.entries(errorMappings)) {
|
||||
if (errorMessage.includes(key)) {
|
||||
userMessage = message;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new ErrorWithCode(ErrorCode.ChargeCardFailure, userMessage);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { makeSqlCondition } from "../server";
|
||||
import { ColumnFilterType } from "../types";
|
||||
import type {
|
||||
MultiSelectFilterValue,
|
||||
SingleSelectFilterValue,
|
||||
TextFilterValue,
|
||||
NumberFilterValue,
|
||||
} from "../types";
|
||||
|
||||
describe("makeSqlCondition", () => {
|
||||
describe("Multi-select filter values", () => {
|
||||
it("should create ANY condition for multi-select values", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["option1", "option2", "option3"],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${["option1", "option2", "option3"]})`);
|
||||
});
|
||||
|
||||
it("should work with table alias", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [1, 2, 3],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${[1, 2, 3]})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Single-select filter values", () => {
|
||||
it("should create equals condition for single-select values", () => {
|
||||
const filterValue: SingleSelectFilterValue = {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: "option1",
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${"option1"}`);
|
||||
});
|
||||
|
||||
it("should work with numeric values", () => {
|
||||
const filterValue: SingleSelectFilterValue = {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: 123,
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${123}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Text filter values", () => {
|
||||
it("should create equals condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${"test"}`);
|
||||
});
|
||||
|
||||
it("should create not equals condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "notEquals", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ${"test"}`);
|
||||
});
|
||||
|
||||
it("should create contains condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`%test%`}`);
|
||||
});
|
||||
|
||||
it("should create not contains condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "notContains", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`NOT ILIKE ${`%test%`}`);
|
||||
});
|
||||
|
||||
it("should create starts with condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "startsWith", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`test%`}`);
|
||||
});
|
||||
|
||||
it("should create ends with condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "endsWith", operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`ILIKE ${`%test`}`);
|
||||
});
|
||||
|
||||
it("should create is empty condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "isEmpty", operand: "" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ''`);
|
||||
});
|
||||
|
||||
it("should create is not empty condition", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "isNotEmpty", operand: "" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ''`);
|
||||
});
|
||||
|
||||
it("should return null for unknown text operator", () => {
|
||||
const filterValue: TextFilterValue = {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "unknown" as any, operand: "test" },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number filter values", () => {
|
||||
it("should create equals condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "eq", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ${42}`);
|
||||
});
|
||||
|
||||
it("should create not equals condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "neq", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`!= ${42}`);
|
||||
});
|
||||
|
||||
it("should create greater than condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "gt", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`> ${42}`);
|
||||
});
|
||||
|
||||
it("should create greater than or equal condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "gte", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`>= ${42}`);
|
||||
});
|
||||
|
||||
it("should create less than condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "lt", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`< ${42}`);
|
||||
});
|
||||
|
||||
it("should create less than or equal condition", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "lte", operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`<= ${42}`);
|
||||
});
|
||||
|
||||
it("should return null for unknown number operator", () => {
|
||||
const filterValue: NumberFilterValue = {
|
||||
type: ColumnFilterType.NUMBER,
|
||||
data: { operator: "unknown" as any, operand: 42 },
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should return null for unknown filter type", () => {
|
||||
const filterValue = {
|
||||
type: "unknown" as any,
|
||||
data: "test",
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty multi-select array", () => {
|
||||
const filterValue: MultiSelectFilterValue = {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [],
|
||||
};
|
||||
|
||||
const result = makeSqlCondition(filterValue);
|
||||
|
||||
expect(result).toEqual(Prisma.sql`= ANY(${[]})`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import type { FilterValue, SortingState } from "./types";
|
||||
import {
|
||||
isSingleSelectFilterValue,
|
||||
@@ -21,6 +23,9 @@ export function makeOrderBy(sorting: SortingState) {
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Prisma where clause for use with Prisma queries
|
||||
*/
|
||||
export function makeWhereClause(props: MakeWhereClauseProps) {
|
||||
const { columnName, filterValue } = props;
|
||||
const isJson = props.json === true || (typeof props.json === "object" && props.json.path?.length > 0);
|
||||
@@ -171,3 +176,56 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
|
||||
}
|
||||
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a SQL where clause for use with raw SQL queries
|
||||
*/
|
||||
export function makeSqlCondition(filterValue: FilterValue): Prisma.Sql | null {
|
||||
if (isMultiSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`= ANY(${filterValue.data})`;
|
||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`= ${filterValue.data}`;
|
||||
} else if (isTextFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
switch (operator) {
|
||||
case "equals":
|
||||
return Prisma.sql`= ${operand}`;
|
||||
case "notEquals":
|
||||
return Prisma.sql`!= ${operand}`;
|
||||
case "contains":
|
||||
return Prisma.sql`ILIKE ${`%${operand}%`}`;
|
||||
case "notContains":
|
||||
return Prisma.sql`NOT ILIKE ${`%${operand}%`}`;
|
||||
case "startsWith":
|
||||
return Prisma.sql`ILIKE ${`${operand}%`}`;
|
||||
case "endsWith":
|
||||
return Prisma.sql`ILIKE ${`%${operand}`}`;
|
||||
case "isEmpty":
|
||||
return Prisma.sql`= ''`;
|
||||
case "isNotEmpty":
|
||||
return Prisma.sql`!= ''`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
} else if (isNumberFilterValue(filterValue)) {
|
||||
const { operator, operand } = filterValue.data;
|
||||
switch (operator) {
|
||||
case "eq":
|
||||
return Prisma.sql`= ${operand}`;
|
||||
case "neq":
|
||||
return Prisma.sql`!= ${operand}`;
|
||||
case "gt":
|
||||
return Prisma.sql`> ${operand}`;
|
||||
case "gte":
|
||||
return Prisma.sql`>= ${operand}`;
|
||||
case "lt":
|
||||
return Prisma.sql`< ${operand}`;
|
||||
case "lte":
|
||||
return Prisma.sql`<= ${operand}`;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { TextField } from "@calcom/ui/components/form";
|
||||
import { ImageUploader } from "@calcom/ui/components/image-uploader";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
|
||||
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
|
||||
|
||||
import { subdomainSuffix } from "../../../organizations/lib/orgDomains";
|
||||
@@ -66,6 +67,13 @@ const OtherTeamProfileView = () => {
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
if (team?.slug) {
|
||||
// Org admins editing another team's profile should purge the cached team data
|
||||
revalidateTeamDataCache({
|
||||
teamSlug: team.slug,
|
||||
orgSlug: team.parent?.slug ?? null,
|
||||
});
|
||||
}
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
|
||||
|
||||
import RoundRobinResetInterval from "./RoundRobinResetInterval";
|
||||
import RoundRobinTimestampBasis from "./RoundRobinTimestampBasis";
|
||||
@@ -38,6 +39,13 @@ const RoundRobinSettings = ({ team }: RoundRobinSettingsProps) => {
|
||||
},
|
||||
async onSuccess() {
|
||||
await utils.viewer.teams.get.invalidate();
|
||||
if (team?.slug) {
|
||||
// Rounb robin reset interval / basis governs host selection logic on the booking page.
|
||||
revalidateTeamDataCache({
|
||||
teamSlug: team.slug,
|
||||
orgSlug: team.parent?.slug ?? null,
|
||||
});
|
||||
}
|
||||
showToast(t("round_robin_settings_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import TeamListItem from "./TeamListItem";
|
||||
|
||||
interface Props {
|
||||
teams: RouterOutputs["viewer"]["teams"]["list"];
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
orgId: number | null;
|
||||
/**
|
||||
* True for teams that are pending invite acceptance
|
||||
*/
|
||||
@@ -28,7 +28,7 @@ export default function TeamList(props: Props) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { t } = useLocale();
|
||||
const { user } = props;
|
||||
const { orgId } = props;
|
||||
|
||||
const [hideDropdown, setHideDropdown] = useState(false);
|
||||
|
||||
@@ -56,13 +56,11 @@ export default function TeamList(props: Props) {
|
||||
deleteTeamMutation.mutate({ teamId });
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
const isUserAlreadyInAnOrganization = user.profile.organization;
|
||||
return (
|
||||
<ul className="bg-default divide-subtle border-subtle mb-2 divide-y overflow-hidden rounded-md border">
|
||||
{ORG_SELF_SERVE_ENABLED &&
|
||||
!props.pending &&
|
||||
!isUserAlreadyInAnOrganization &&
|
||||
!orgId &&
|
||||
props.teams.length >= ORG_MINIMUM_PUBLISHED_TEAMS_SELF_SERVE_HELPER_DIALOGUE &&
|
||||
props.teams.map(
|
||||
(team, i) =>
|
||||
@@ -116,7 +114,7 @@ export default function TeamList(props: Props) {
|
||||
<TeamListItem
|
||||
key={team?.id as number}
|
||||
team={team}
|
||||
user={user}
|
||||
orgId={orgId}
|
||||
onActionSelect={(action: string) => selectAction(action, team?.id as number)}
|
||||
isPending={deleteTeamMutation.isPending}
|
||||
hideDropdown={hideDropdown}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { TeamRole } from "./TeamPill";
|
||||
|
||||
interface Props {
|
||||
team: RouterOutputs["viewer"]["teams"]["list"][number];
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
orgId: number | null;
|
||||
key: number;
|
||||
onActionSelect: (text: string) => void;
|
||||
isPending?: boolean;
|
||||
@@ -48,7 +48,7 @@ export default function TeamListItem(props: Props) {
|
||||
const searchParams = useCompatSearchParams();
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { team, user } = props;
|
||||
const { team, orgId } = props;
|
||||
|
||||
const showDialog = searchParams?.get("inviteModal") === "true";
|
||||
const [openMemberInvitationModal, setOpenMemberInvitationModal] = useState(showDialog);
|
||||
@@ -63,7 +63,7 @@ export default function TeamListItem(props: Props) {
|
||||
revalidateTeamsList();
|
||||
utils.viewer.teams.hasTeamPlan.invalidate();
|
||||
utils.viewer.teams.listInvites.invalidate();
|
||||
const userOrganizationId = user?.profile?.organization?.id;
|
||||
const userOrganizationId = orgId ?? undefined;
|
||||
const isSubTeamOfDifferentOrg = team.parentId ? team.parentId != userOrganizationId : false;
|
||||
const isDifferentOrg = team.isOrganization && team.id !== userOrganizationId;
|
||||
// If the user team being accepted is a sub-team of different organization or the different organization itself then page must be reloaded to let the session change reflect reliably everywhere.
|
||||
|
||||
@@ -18,14 +18,16 @@ import { UpgradeTip } from "../../../tips";
|
||||
import TeamList from "./TeamList";
|
||||
|
||||
type TeamsListingProps = {
|
||||
user: RouterOutputs["viewer"]["me"]["get"];
|
||||
orgId: number | null;
|
||||
isOrgAdmin: boolean;
|
||||
teams: RouterOutputs["viewer"]["teams"]["list"];
|
||||
teamNameFromInvite: string | null;
|
||||
errorMsgFromInvite: string | null;
|
||||
};
|
||||
|
||||
export function TeamsListing({
|
||||
user,
|
||||
orgId,
|
||||
isOrgAdmin,
|
||||
teams: data,
|
||||
teamNameFromInvite,
|
||||
errorMsgFromInvite,
|
||||
@@ -49,7 +51,7 @@ export function TeamsListing({
|
||||
}
|
||||
);
|
||||
|
||||
const isCreateTeamButtonDisabled = !!(user?.organizationId && !user?.organization?.isOrgAdmin);
|
||||
const isCreateTeamButtonDisabled = !!(orgId && !isOrgAdmin);
|
||||
|
||||
const features = [
|
||||
{
|
||||
@@ -105,18 +107,18 @@ export function TeamsListing({
|
||||
{organizationInvites.length > 0 && (
|
||||
<div className="bg-subtle mb-6 rounded-md p-5">
|
||||
<Label className="text-emphasis pb-2 font-semibold">{t("pending_organization_invites")}</Label>
|
||||
<TeamList user={user} teams={organizationInvites} pending />
|
||||
<TeamList orgId={orgId} teams={organizationInvites} pending />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teamInvites.length > 0 && (
|
||||
<div className="bg-subtle mb-6 rounded-md p-5">
|
||||
<Label className="text-emphasis pb-2 font-semibold">{t("pending_invites")}</Label>
|
||||
<TeamList user={user} teams={teamInvites} pending />
|
||||
<TeamList orgId={orgId} teams={teamInvites} pending />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{teams.length > 0 && <TeamList user={user} teams={teams} />}
|
||||
{teams.length > 0 && <TeamList orgId={orgId} teams={teams} />}
|
||||
|
||||
{teams.length === 0 && (
|
||||
<UpgradeTip
|
||||
@@ -126,7 +128,7 @@ export function TeamsListing({
|
||||
features={features}
|
||||
background="/tips/teams"
|
||||
buttons={
|
||||
!user?.organizationId || user?.organization.isOrgAdmin ? (
|
||||
!orgId || isOrgAdmin ? (
|
||||
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
|
||||
<ButtonGroup>
|
||||
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
export type TeamData = Awaited<ReturnType<typeof getTeamData>>;
|
||||
|
||||
export async function getTeamData(teamSlug: string, orgSlug: string | null) {
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: orgSlug ? getSlugOrRequestedSlug(orgSlug) : null,
|
||||
},
|
||||
orderBy: {
|
||||
slug: { sort: "asc", nulls: "last" },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
theme: true,
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@calcom/ui/components/button";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
|
||||
|
||||
import ThemeLabel from "../../../settings/ThemeLabel";
|
||||
|
||||
@@ -71,6 +72,14 @@ const ProfileView = ({ team }: ProfileViewProps) => {
|
||||
}
|
||||
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
if (res?.slug) {
|
||||
// Appearance changes (theme, colours, branding toggles) are read on the team booking page through
|
||||
// `getCachedTeamData` in `queries.ts`.
|
||||
await revalidateTeamDataCache({
|
||||
teamSlug: res?.slug,
|
||||
orgSlug: team?.parent?.slug ?? null,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
|
||||
import { revalidateEventTypesList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/event-types/actions";
|
||||
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
|
||||
|
||||
@@ -283,6 +284,14 @@ const TeamProfileForm = ({ team, teamId }: TeamProfileFormProps) => {
|
||||
// TODO: Not all changes require list invalidation
|
||||
await utils.viewer.teams.list.invalidate();
|
||||
revalidateTeamsList();
|
||||
|
||||
if (res?.slug) {
|
||||
await revalidateTeamDataCache({
|
||||
teamSlug: res.slug,
|
||||
orgSlug: team?.parent?.slug ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
showToast(t("your_team_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ import { Form } from "@calcom/ui/components/form";
|
||||
import { SettingsToggle } from "@calcom/ui/components/form";
|
||||
import { CheckboxField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { revalidateTeamDataCache } from "@calcom/web/app/(booking-page-wrapper)/team/[slug]/[type]/actions";
|
||||
|
||||
import DisableTeamImpersonation from "../components/DisableTeamImpersonation";
|
||||
import { default as InternalNotePresetsView } from "../components/InternalNotePresetsView";
|
||||
@@ -57,6 +58,13 @@ const BookingLimitsView = ({ team }: ProfileViewProps) => {
|
||||
includeManagedEventsInLimits: res.includeManagedEventsInLimits,
|
||||
});
|
||||
}
|
||||
if (team?.slug) {
|
||||
// Booking limits are enforced during slot generation, which relies on the same cached team data.
|
||||
revalidateTeamDataCache({
|
||||
teamSlug: team.slug,
|
||||
orgSlug: team.parent?.slug ?? null,
|
||||
});
|
||||
}
|
||||
showToast(t("booking_limits_updated_successfully"), "success");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ export async function handler(req: NextRequest) {
|
||||
method: WorkflowMethods.SMS,
|
||||
scheduled: false,
|
||||
scheduledDate: {
|
||||
gte: new Date(),
|
||||
lte: dayjs().add(2, "hour").toISOString(),
|
||||
},
|
||||
retryCount: {
|
||||
|
||||
@@ -24,24 +24,18 @@ export async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
//delete all scheduled whatsapp reminders where scheduled date is past current date
|
||||
await prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
method: WorkflowMethods.WHATSAPP,
|
||||
scheduledDate: {
|
||||
lte: dayjs().toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
//find all unscheduled WHATSAPP reminders
|
||||
const unscheduledReminders = (await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
method: WorkflowMethods.WHATSAPP,
|
||||
scheduled: false,
|
||||
scheduledDate: {
|
||||
gte: new Date(),
|
||||
lte: dayjs().add(2, "hour").toISOString(),
|
||||
},
|
||||
retryCount: {
|
||||
lt: 3, // Don't continue retrying if it's already failed 3 times
|
||||
},
|
||||
},
|
||||
select,
|
||||
})) as PartialWorkflowReminder[];
|
||||
|
||||
@@ -206,8 +206,12 @@ export async function getAllUnscheduledReminders(): Promise<PartialWorkflowRemin
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduled: false,
|
||||
scheduledDate: {
|
||||
gte: new Date(),
|
||||
lte: dayjs().add(2, "hour").toISOString(),
|
||||
},
|
||||
retryCount: {
|
||||
lt: 3, // Don't continue retrying if it's already failed 3 times
|
||||
},
|
||||
OR: [{ cancelled: false }, { cancelled: null }],
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user