Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cbc8a3816 | ||
|
|
4e1048b221 | ||
|
|
cb78692347 | ||
|
|
3784421f02 | ||
|
|
e7535b9db3 | ||
|
|
bb4260c98b | ||
|
|
9d65b6f794 | ||
|
|
4874c6c2ee | ||
|
|
30a92a4d66 | ||
|
|
04ce054cf7 | ||
|
|
e901e8d189 | ||
|
|
0aa595ddac | ||
|
|
2115a045ec | ||
|
|
f67522dbbd | ||
|
|
dc5aef0dd4 | ||
|
|
e1d7631215 | ||
|
|
05e1ad0c91 | ||
|
|
cf392562da | ||
|
|
c8de8ca681 | ||
|
|
2c91c0e309 | ||
|
|
cef007d852 | ||
|
|
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 | ||
|
|
b84405c5eb | ||
|
|
2743b32536 | ||
|
|
a251422f7a | ||
|
|
d7c9bd6ed8 | ||
|
|
8b3ff0b789 | ||
|
|
94a537bfda | ||
|
|
64305ffee1 | ||
|
|
45698db0b4 | ||
|
|
14e14289f0 | ||
|
|
18650b1215 | ||
|
|
d2bbbf3f45 | ||
|
|
13cb99b7fe | ||
|
|
a1571d01bd | ||
|
|
bbd8af074e | ||
|
|
c9a47dd0ce | ||
|
|
1c9778e667 | ||
|
|
758143ef70 | ||
|
|
b5911a9a2d | ||
|
|
260f4e1b51 | ||
|
|
e6c36af3b4 | ||
|
|
2fb2458eee | ||
|
|
b340b0edbe | ||
|
|
f7fd1c4402 | ||
|
|
c316721051 | ||
|
|
80ac118242 | ||
|
|
efd15c5a3e | ||
|
|
7d02d1bb23 | ||
|
|
0d877e7be3 | ||
|
|
e4c47640fc | ||
|
|
89e7fca183 | ||
|
|
33b5bf1db8 | ||
|
|
f69c4a900e | ||
|
|
1da25cc85f | ||
|
|
3a65b372c5 | ||
|
|
9892d521fb | ||
|
|
3285b252c2 | ||
|
|
97af2fc0f9 | ||
|
|
5c508bce6d | ||
|
|
db84343581 | ||
|
|
2b44d09191 | ||
|
|
10d67aee86 | ||
|
|
b33bbb8af0 | ||
|
|
3fe9327724 | ||
|
|
179a547bd0 | ||
|
|
7ada02177d | ||
|
|
59b55e40b5 | ||
|
|
19563aa697 | ||
|
|
6410687fb6 | ||
|
|
4e4c8f5f49 | ||
|
|
416b17cf69 | ||
|
|
27cd7c8496 | ||
|
|
02321c33b2 | ||
|
|
c3dab3e4f8 | ||
|
|
0c182e2813 | ||
|
|
51a076212b | ||
|
|
27dce7374b | ||
|
|
52f2c18800 | ||
|
|
35a8918ea9 | ||
|
|
b39eab72e7 | ||
|
|
928b2477be | ||
|
|
dc13251bcc | ||
|
|
4843658dfb | ||
|
|
076afdc00e | ||
|
|
4404699fa6 | ||
|
|
52940ce254 | ||
|
|
cd6293d220 | ||
|
|
108f95723b | ||
|
|
f611c3d767 | ||
|
|
6091208f8a | ||
|
|
585a12fa48 |
@@ -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
|
||||
|
||||
@@ -17,6 +17,10 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
- uses: lingodotdev/lingo.dev@main
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
@@ -80,6 +80,8 @@ That's where Cal.com comes in. Self-hosted or hosted by us. White-label by desig
|
||||
|
||||
<a href="https://producthunt.com/posts/calendso?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=291910&theme=light&period=monthly" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/posts/calendso?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-calendso" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=291910&theme=light" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://producthunt.com/stories/how-this-open-source-calendly-alternative-rocketed-to-product-of-the-day" target="_blank"><img src="https://cal.com/maker-grant.svg" alt="Cal.com - The open source Calendly alternative | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
This project is tested with browserstack
|
||||
|
||||
### Built With
|
||||
|
||||
- [Next.js](https://nextjs.org/?ref=cal.com)
|
||||
@@ -488,13 +490,8 @@ Don't code but still want to contribute? Join our [Discussions](https://github.c
|
||||
- Set CSP_POLICY="non-strict" env variable, which enables [Strict CSP](https://web.dev/strict-csp/) except for unsafe-inline in style-src . If you have some custom changes in your instance, you might have to make some code change to make your instance CSP compatible. Right now it enables strict CSP only on login page and on other SSR pages it is enabled in Report only mode to detect possible issues. On, SSG pages it is still not supported.
|
||||
|
||||
## Single Org Mode
|
||||
If you want to have booker.yourcompany.com to be the domain used for both dashboard(e.g. https://booker.yourcompany.com/event-types) and booking pages(e.g. https://booker.yourcompany.com/john.joe/15min).
|
||||
- Set the `NEXT_PUBLIC_SINGLE_ORG_SLUG` environment variable to the slug of the organization you want to use. `NEXT_PUBLIC_SINGLE_ORG_SLUG=booker`
|
||||
- Set the `NEXT_PUBLIC_WEBAPP_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBAPP_URL=https://booker.yourcompany.com`.
|
||||
- Set the `NEXT_PUBLIC_WEBSITE_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXT_PUBLIC_WEBSITE_URL=https://booker.yourcompany.com`.
|
||||
- Set the `NEXTAUTH_URL` environment variable to the URL of the Cal.com self-hosted instance e.g. `NEXTAUTH_URL=https://booker.yourcompany.com`.
|
||||
Refer to docs [here](./docs/self-hosting/guides/organization/single-organization-setup) for a detailed documentation with screenshots.
|
||||
|
||||
Note: It causes root to serve the dashboard and not the organization profile page which shows all bookable users in the organization.
|
||||
|
||||
## Integrations
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ afterEach(() => {
|
||||
|
||||
const mockDeploymentRepository: IDeploymentRepository = {
|
||||
getLicenseKeyWithId: vi.fn().mockResolvedValue("mockLicenseKey"), // Mocked return value
|
||||
getSignatureToken: vi.fn().mockResolvedValue("mockSignatureToken"),
|
||||
};
|
||||
|
||||
// TODO: Fix the skip condition for this test suite
|
||||
|
||||
@@ -43,6 +43,7 @@ enum locales {
|
||||
SR = "sr",
|
||||
SV = "sv",
|
||||
VI = "vi",
|
||||
BN = "bn",
|
||||
}
|
||||
enum theme {
|
||||
DARK = "dark",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -105,7 +105,9 @@ async function getHandler(req: NextApiRequest) {
|
||||
|
||||
const userIds = req.query.userId ? extractUserIdsFromQuery(req) : [userId];
|
||||
|
||||
const usersWithCalendars = await UserRepository.findManyByIdsIncludeDestinationAndSelectedCalendars({
|
||||
const usersWithCalendars = await new UserRepository(
|
||||
prisma
|
||||
).findManyByIdsIncludeDestinationAndSelectedCalendars({
|
||||
ids: userIds,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,11 +4,11 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/available-slots";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
@@ -27,7 +27,13 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
const input = getScheduleSchema.parse({ usernameList: slugs, isTeamEvent: parsedIsTeamEvent, ...rest });
|
||||
const timeZoneSupported = input.timeZone ? isSupportedTimeZone(input.timeZone) : false;
|
||||
const availableSlots = await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||
|
||||
const availableSlotsService = getAvailableSlotsService();
|
||||
const availableSlots = await availableSlotsService.getAvailableSlots({
|
||||
ctx: await createContext({ req, res }),
|
||||
input,
|
||||
});
|
||||
|
||||
const slotsInProvidedTimeZone = timeZoneSupported
|
||||
? Object.keys(availableSlots.slots).reduce(
|
||||
(acc: Record<string, { time: string; attendees?: number; bookingUid?: string }[]>, date) => {
|
||||
|
||||
@@ -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.239",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.258",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -443,7 +443,7 @@ export class BookingsController_2024_08_13 {
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
summary: "Get booking references",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
|
||||
@@ -1425,8 +1425,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
// @ts-ignore
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.status).toEqual("cancelled");
|
||||
|
||||
createdBooking = data;
|
||||
expect(data.rescheduledToUid).toEqual(rescheduledBooking.uid);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1582,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)
|
||||
@@ -1611,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)
|
||||
@@ -1644,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");
|
||||
@@ -1654,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"
|
||||
|
||||
@@ -596,9 +596,9 @@ export class BookingsService_2024_08_13 {
|
||||
} else if (isRecurring && isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
|
||||
} else if (isSeated) {
|
||||
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
|
||||
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted));
|
||||
} else {
|
||||
formattedBookings.push(this.outputService.getOutputBooking(formatted));
|
||||
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -190,9 +190,20 @@ export class InputBookingsService_2024_08_13 {
|
||||
};
|
||||
}
|
||||
|
||||
private getRoutingFormData(routing: { teamMemberIds?: number[]; responseId?: number; teamMemberEmail?: string; skipContactOwner?: boolean; crmAppSlug?: string; crmOwnerRecordType?: string } | undefined) {
|
||||
private getRoutingFormData(
|
||||
routing:
|
||||
| {
|
||||
teamMemberIds?: number[];
|
||||
responseId?: number;
|
||||
teamMemberEmail?: string;
|
||||
skipContactOwner?: boolean;
|
||||
crmAppSlug?: string;
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
| undefined
|
||||
) {
|
||||
if (!routing) return null;
|
||||
|
||||
|
||||
return {
|
||||
routedTeamMemberIds: routing.teamMemberIds,
|
||||
routingFormResponseId: routing.responseId,
|
||||
|
||||
@@ -90,7 +90,7 @@ type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
|
||||
export class OutputBookingsService_2024_08_13 {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
|
||||
getOutputBooking(databaseBooking: DatabaseBooking) {
|
||||
async getOutputBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
@@ -101,6 +101,9 @@ export class OutputBookingsService_2024_08_13 {
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -109,8 +112,9 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
cancelledByEmail: databaseBooking.cancelledBy || undefined,
|
||||
cancellationReason:
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
@@ -137,6 +141,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -146,6 +151,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getRescheduledToUid(bookingUid: string) {
|
||||
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
|
||||
return rescheduledTo?.uid;
|
||||
}
|
||||
|
||||
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
|
||||
if (databaseMetadata === null) return {};
|
||||
|
||||
@@ -204,8 +214,9 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
cancelledByEmail: databaseBooking.cancelledBy || undefined,
|
||||
cancellationReason:
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
|
||||
reschedulingReason: bookingResponses?.rescheduledReason,
|
||||
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
@@ -244,20 +255,23 @@ export class OutputBookingsService_2024_08_13 {
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
getOutputCreateSeatedBooking(
|
||||
async getOutputCreateSeatedBooking(
|
||||
databaseBooking: DatabaseBooking,
|
||||
seatUid: string
|
||||
): CreateSeatedBookingOutput_2024_08_13 {
|
||||
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
|
||||
): Promise<CreateSeatedBookingOutput_2024_08_13> {
|
||||
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking);
|
||||
return { ...getSeatedBookingOutput, seatUid };
|
||||
}
|
||||
|
||||
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
async getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
const rescheduledToUid = databaseBooking.rescheduled
|
||||
? await this.getRescheduledToUid(databaseBooking.uid)
|
||||
: undefined;
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -266,7 +280,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
cancellationReason:
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
@@ -283,6 +298,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
rescheduledToUid,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -375,7 +391,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
description: databaseBooking.description,
|
||||
hosts: [this.getHost(databaseBooking.user)],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
cancellationReason:
|
||||
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
|
||||
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+3
-1
@@ -124,7 +124,9 @@ export class EventTypesController_2024_04_15 {
|
||||
let orgSlug = queryParams.org;
|
||||
|
||||
if (clientId && !orgSlug && username.includes(`-${clientId}`)) {
|
||||
const org = await this.organizationsRepository.findTeamIdAndSlugFromClientId(clientId).catch(() => null);
|
||||
const org = await this.organizationsRepository
|
||||
.findTeamIdAndSlugFromClientId(clientId)
|
||||
.catch(() => null);
|
||||
if (org) {
|
||||
orgSlug = org.slug;
|
||||
}
|
||||
|
||||
@@ -3,4 +3,5 @@ export enum AuthMethods {
|
||||
"ACCESS_TOKEN" = "access-token",
|
||||
"OAUTH_CLIENT" = "oauth-client",
|
||||
"NEXT_AUTH" = "next-auth",
|
||||
"THIRD_PARTY_ACCESS_TOKEN" = "third_party_access_token",
|
||||
}
|
||||
|
||||
@@ -41,4 +41,5 @@ export enum Locales {
|
||||
TA = "ta",
|
||||
UK = "uk",
|
||||
ZH_TW = "zh-TW",
|
||||
BN = "bn",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
PrismaOOORepository,
|
||||
PrismaScheduleRepository,
|
||||
PrismaBookingRepository,
|
||||
PrismaSelectedSlotsRepository,
|
||||
PrismaUserRepository,
|
||||
PrismaEventTypeRepository,
|
||||
PrismaRoutingFormResponseRepository,
|
||||
PrismaTeamRepository,
|
||||
AvailableSlotsService,
|
||||
],
|
||||
exports: [AvailableSlotsService],
|
||||
})
|
||||
export class AvailableSlotsModule {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaBookingRepository as PrismaBookingRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaBookingRepository extends PrismaBookingRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaEventTypeRepository as PrismaEventTypeRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaEventTypeRepository extends PrismaEventTypeRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaOOORepository as PrismaOOORepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaOOORepository extends PrismaOOORepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaRoutingFormResponseRepository as PrismaRoutingFormResponseRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaRoutingFormResponseRepository extends PrismaRoutingFormResponseRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaScheduleRepository as PrismaScheduleRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaScheduleRepository extends PrismaScheduleRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaSelectedSlotsRepository as PrismaSelectedSlotsRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaSelectedSlotsRepository extends PrismaSelectedSlotsRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaTeamRepository as PrismaTeamRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaTeamRepository extends PrismaTeamRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { PrismaUserRepository as PrismaUserRepositoryLib } from "@calcom/platform-libraries/repositories";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class PrismaUserRepository extends PrismaUserRepositoryLib {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {
|
||||
super(dbWrite.prisma as unknown as PrismaClient);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
|
||||
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
|
||||
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
|
||||
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
|
||||
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
|
||||
import { PrismaSelectedSlotsRepository } from "@/lib/repositories/prisma-selected-slots.repository";
|
||||
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
|
||||
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
|
||||
|
||||
@Injectable()
|
||||
export class AvailableSlotsService extends BaseAvailableSlotsService {
|
||||
constructor(
|
||||
private readonly oooRepoDependency: PrismaOOORepository,
|
||||
private readonly scheduleRepoDependency: PrismaScheduleRepository,
|
||||
private readonly teamRepository: PrismaTeamRepository,
|
||||
private readonly routingFormResponseRepository: PrismaRoutingFormResponseRepository,
|
||||
private readonly bookingRepository: PrismaBookingRepository,
|
||||
private readonly selectedSlotsRepository: PrismaSelectedSlotsRepository,
|
||||
private readonly eventTypeRepository: PrismaEventTypeRepository,
|
||||
private readonly userRepository: PrismaUserRepository
|
||||
) {
|
||||
super({
|
||||
oooRepo: oooRepoDependency,
|
||||
scheduleRepo: scheduleRepoDependency,
|
||||
teamRepo: teamRepository,
|
||||
routingFormResponseRepo: routingFormResponseRepository,
|
||||
bookingRepo: bookingRepository,
|
||||
selectedSlotsRepo: selectedSlotsRepository,
|
||||
eventTypeRepo: eventTypeRepository,
|
||||
userRepo: userRepository,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
|
||||
export type AllowedAuthMethod =
|
||||
| "OAUTH_CLIENT_CREDENTIALS"
|
||||
| "API_KEY"
|
||||
| "ACCESS_TOKEN"
|
||||
| "NEXT_AUTH"
|
||||
| "THIRD_PARTY_ACCESS_TOKEN";
|
||||
|
||||
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -19,6 +20,7 @@ describe("PermissionsGuard", () => {
|
||||
guard = new PermissionsGuard(
|
||||
reflector,
|
||||
createMock<TokensRepository>(),
|
||||
createMock<TokensService>(),
|
||||
createMock<ConfigService>({
|
||||
get: jest.fn().mockImplementation((key: string) => {
|
||||
switch (key) {
|
||||
@@ -118,6 +120,7 @@ describe("PermissionsGuard", () => {
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
|
||||
@@ -133,11 +136,22 @@ describe("PermissionsGuard", () => {
|
||||
jest
|
||||
.spyOn(guard, "getOAuthClientByAccessToken")
|
||||
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
|
||||
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue(null);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(
|
||||
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
|
||||
);
|
||||
});
|
||||
|
||||
it("should return true for 3rd party access token", async () => {
|
||||
const mockContext = createMockExecutionContext({ Authorization: "Bearer token" });
|
||||
jest.spyOn(guard, "getDecodedThirdPartyAccessToken").mockReturnValue({
|
||||
scope: ["scope"],
|
||||
token_type: "Bearer",
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when OAuth id is provided", () => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
@@ -17,6 +18,7 @@ export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private tokensRepository: TokensRepository,
|
||||
private tokensService: TokensService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository,
|
||||
private readonly oAuthClientsOutputService: OAuthClientsOutputService
|
||||
@@ -35,9 +37,10 @@ export class PermissionsGuard implements CanActivate {
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
|
||||
const isThirdPartyBearerToken = bearerToken && this.getDecodedThirdPartyAccessToken(bearerToken);
|
||||
|
||||
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
|
||||
if (nextAuthToken || apiKey) {
|
||||
// only check permissions for accessTokens attached to platform oAuth Client or platform oAuth credentials, not for next token or api key or third party oauth client
|
||||
if (nextAuthToken || apiKey || isThirdPartyBearerToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -87,4 +90,8 @@ export class PermissionsGuard implements CanActivate {
|
||||
}
|
||||
return oAuthClient;
|
||||
}
|
||||
|
||||
getDecodedThirdPartyAccessToken(bearerToken: string) {
|
||||
return this.tokensService.getDecodedThirdPartyAccessToken(bearerToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.se
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { ExecutionContext, HttpException } from "@nestjs/common";
|
||||
@@ -64,6 +64,7 @@ describe("ApiAuthStrategy", () => {
|
||||
load: [appConfig],
|
||||
}),
|
||||
ProfilesModule,
|
||||
TokensModule,
|
||||
],
|
||||
providers: [
|
||||
MockedRedisService,
|
||||
@@ -77,7 +78,6 @@ describe("ApiAuthStrategy", () => {
|
||||
OAuthClientRepository,
|
||||
PrismaReadService,
|
||||
PrismaWriteService,
|
||||
TokensRepository,
|
||||
JwtService,
|
||||
DeploymentsRepository,
|
||||
NestJwtService,
|
||||
|
||||
@@ -8,9 +8,11 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import type { Request } from "express";
|
||||
@@ -29,13 +31,17 @@ export type ApiAuthGuardRequest = Request & {
|
||||
};
|
||||
export const NO_AUTH_PROVIDED_MESSAGE =
|
||||
"No authentication method provided. Either pass an API key as 'Bearer' header or OAuth client credentials as 'x-cal-secret-key' and 'x-cal-client-id' headers";
|
||||
|
||||
@Injectable()
|
||||
export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth") {
|
||||
private readonly logger = new Logger("ApiAuthStrategy");
|
||||
|
||||
constructor(
|
||||
private readonly deploymentsService: DeploymentsService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly oauthFlowService: OAuthFlowService,
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly tokensService: TokensService,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
@@ -54,22 +60,53 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
const allowedMethods = request.allowedAuthMethods;
|
||||
const noSpecificAuthExpected = !allowedMethods || !allowedMethods.length;
|
||||
|
||||
const oAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("OAUTH_CLIENT_CREDENTIALS");
|
||||
const apiKeyAllowed = noSpecificAuthExpected || allowedMethods.includes("API_KEY");
|
||||
const accessTokenAllowed = noSpecificAuthExpected || allowedMethods.includes("ACCESS_TOKEN");
|
||||
const nextAuthAllowed = noSpecificAuthExpected || allowedMethods.includes("NEXT_AUTH");
|
||||
const thirdPartyAccessTokenAllowed =
|
||||
noSpecificAuthExpected || allowedMethods.includes("THIRD_PARTY_ACCESS_TOKEN");
|
||||
|
||||
if (oAuthClientId && oAuthClientSecret && oAuthAllowed) {
|
||||
request.authMethod = AuthMethods["OAUTH_CLIENT"];
|
||||
return await this.authenticateOAuthClient(oAuthClientId, oAuthClientSecret, request);
|
||||
}
|
||||
|
||||
if (bearerToken && (apiKeyAllowed || accessTokenAllowed)) {
|
||||
const requestOrigin = request.get("Origin");
|
||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? AuthMethods["API_KEY"]
|
||||
: AuthMethods["ACCESS_TOKEN"];
|
||||
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
|
||||
if (bearerToken) {
|
||||
if (!apiKeyAllowed && !accessTokenAllowed && thirdPartyAccessTokenAllowed) {
|
||||
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
|
||||
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
|
||||
if (result.success) {
|
||||
return this.success(this.getSuccessUser(result.data));
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKeyAllowed || accessTokenAllowed) {
|
||||
try {
|
||||
const requestOrigin = request.get("Origin");
|
||||
request.authMethod = isApiKey(bearerToken, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? AuthMethods["API_KEY"]
|
||||
: AuthMethods["ACCESS_TOKEN"];
|
||||
return await this.authenticateBearerToken(bearerToken, request, requestOrigin);
|
||||
} catch (err) {
|
||||
// failed to validate access token, try to validate third party token
|
||||
if (thirdPartyAccessTokenAllowed && request.authMethod === AuthMethods["ACCESS_TOKEN"]) {
|
||||
request.authMethod = AuthMethods["THIRD_PARTY_ACCESS_TOKEN"];
|
||||
const result = await this.validateThirdPartyAccessToken(bearerToken, request);
|
||||
|
||||
if (result.success) {
|
||||
return this.success(this.getSuccessUser(result.data));
|
||||
}
|
||||
}
|
||||
// token was not third party token, rethrow error from authenticateBearerToken
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - Invalid Bearer token`);
|
||||
}
|
||||
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
@@ -83,8 +120,11 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
if (noAuthProvided) {
|
||||
throw new UnauthorizedException(`ApiAuthStrategy - ${NO_AUTH_PROVIDED_MESSAGE}`);
|
||||
}
|
||||
|
||||
throw new UnauthorizedException(
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${allowedMethods}`
|
||||
`ApiAuthStrategy - Invalid authentication method. Please provide one of the allowed methods: ${
|
||||
allowedMethods && allowedMethods.length > 0 ? allowedMethods.join(", ") : "Any supported method"
|
||||
}`
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
@@ -164,21 +204,17 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
: await this.accessTokenStrategy(authString, request, requestOrigin);
|
||||
|
||||
if (!user) {
|
||||
return this.error(
|
||||
new UnauthorizedException(
|
||||
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||
)
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - bearer token - No user associated with the provided token"
|
||||
);
|
||||
}
|
||||
|
||||
return this.success(this.getSuccessUser(user));
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
return this.error(err);
|
||||
throw err;
|
||||
}
|
||||
return this.error(
|
||||
new InternalServerErrorException("An error occurred while authenticating the request")
|
||||
);
|
||||
throw new InternalServerErrorException("An error occurred while authenticating the request");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,4 +307,43 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async validateThirdPartyAccessToken(
|
||||
token: string,
|
||||
request: ApiAuthGuardRequest
|
||||
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
|
||||
const decodedToken = this.tokensService.getDecodedThirdPartyAccessToken(token);
|
||||
if (!decodedToken) {
|
||||
return { success: false };
|
||||
}
|
||||
|
||||
let user: UserWithProfile | null = null;
|
||||
let organizationId: number | null = null;
|
||||
|
||||
if (decodedToken.userId) {
|
||||
user = await this.userRepository.findByIdWithProfile(decodedToken.userId);
|
||||
if (user) {
|
||||
organizationId = this.usersService.getUserMainOrgId(user) as number;
|
||||
}
|
||||
} else if (decodedToken.teamId) {
|
||||
const teamOwner = await this.userRepository.findOwnerByTeamIdWithProfile(decodedToken.teamId);
|
||||
if (!teamOwner) {
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - third-party token - No owner found for the associated team."
|
||||
);
|
||||
}
|
||||
user = teamOwner;
|
||||
organizationId =
|
||||
teamOwner.profiles?.find((p) => p.organizationId === decodedToken.teamId)?.organizationId ?? null;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new UnauthorizedException(
|
||||
"ApiAuthStrategy - third-party token - No user or team owner associated with the token."
|
||||
);
|
||||
}
|
||||
|
||||
request.organizationId = organizationId;
|
||||
return { success: true, data: user };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,12 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [TokensModule],
|
||||
providers: [
|
||||
GCalService,
|
||||
GoogleCalendarService,
|
||||
@@ -22,7 +22,6 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CredentialsRepository,
|
||||
CalendarsService,
|
||||
TokensRepository,
|
||||
SelectedCalendarsRepository,
|
||||
PrismaReadService,
|
||||
PrismaWriteService,
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ export class CalUnifiedCalendarsController {
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get meeting Details from calendar",
|
||||
summary: "Get meeting details from calendar",
|
||||
description: "Returns detailed information about a meeting including attendance metrics",
|
||||
})
|
||||
async getCalendarEventDetails(
|
||||
|
||||
+47
-10
@@ -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 () => {
|
||||
@@ -304,8 +305,10 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("collective");
|
||||
evaluateHost({ userId: firstManagedUser.user.id }, data.hosts[0]);
|
||||
evaluateHost({ userId: secondManagedUser.user.id }, data.hosts[1]);
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost({ userId: firstManagedUser.user.id }, dataFirstHost);
|
||||
evaluateHost({ userId: secondManagedUser.user.id }, dataSecondHost);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
expect(eventTypeHosts.length).toEqual(2);
|
||||
@@ -313,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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,13 +345,15 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("roundRobin");
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost(
|
||||
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[0]
|
||||
dataFirstHost
|
||||
);
|
||||
evaluateHost(
|
||||
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[1]
|
||||
dataSecondHost
|
||||
);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
@@ -361,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",
|
||||
};
|
||||
@@ -381,13 +409,15 @@ describe("Assign all team members", () => {
|
||||
expect(data.title).toEqual(body.title);
|
||||
expect(data.hosts.length).toEqual(2);
|
||||
expect(data.schedulingType).toEqual("roundRobin");
|
||||
const dataFirstHost = data.hosts.find((host) => host.userId === firstManagedUser.user.id);
|
||||
const dataSecondHost = data.hosts.find((host) => host.userId === secondManagedUser.user.id);
|
||||
evaluateHost(
|
||||
{ userId: firstManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[0]
|
||||
dataFirstHost
|
||||
);
|
||||
evaluateHost(
|
||||
{ userId: secondManagedUser.user.id, mandatory: false, priority: "medium" },
|
||||
data.hosts[1]
|
||||
dataSecondHost
|
||||
);
|
||||
|
||||
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
|
||||
@@ -461,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,
|
||||
}),
|
||||
};
|
||||
|
||||
+486
-227
File diff suppressed because it is too large
Load Diff
+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,
|
||||
|
||||
+4
-4
@@ -6,9 +6,9 @@ import { GetAvailableSlotsInput_2024_09_04 } from "@calcom/platform-types";
|
||||
|
||||
export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_09_04 {
|
||||
@Transform(({ value }: { value: string | boolean }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (typeof value === 'string') {
|
||||
return value.toLowerCase() === 'true';
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
return value.toLowerCase() === "true";
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
@@ -20,4 +20,4 @@ export class CreateRoutingFormResponseInput extends GetAvailableSlotsInput_2024_
|
||||
example: true,
|
||||
})
|
||||
queueResponse?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
+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],
|
||||
})
|
||||
|
||||
+8
-6
@@ -2,7 +2,12 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from "@nestjs/swagger
|
||||
import { Type } from "class-transformer";
|
||||
import { IsArray, IsBoolean, IsInt, IsNumber, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ApiResponseWithoutData, SlotsOutput_2024_09_04, RangeSlotsOutput_2024_09_04 } from "@calcom/platform-types";
|
||||
import {
|
||||
ApiResponseWithoutData,
|
||||
SlotsOutput_2024_09_04,
|
||||
RangeSlotsOutput_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
class Routing {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
@@ -81,11 +86,10 @@ export class CreateRoutingFormResponseOutputData {
|
||||
eventTypeId?: number;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: Routing })
|
||||
@Type(() => Routing)
|
||||
@ApiPropertyOptional({
|
||||
type: Routing,
|
||||
description: "The routing information.",
|
||||
description: "The routing information that could be passed as is to the booking API.",
|
||||
example: {
|
||||
eventTypeId: 123,
|
||||
routing: {
|
||||
@@ -95,8 +99,6 @@ export class CreateRoutingFormResponseOutputData {
|
||||
},
|
||||
},
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Routing)
|
||||
routing?: Routing;
|
||||
|
||||
@IsString()
|
||||
@@ -133,4 +135,4 @@ export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
|
||||
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
|
||||
@Type(() => CreateRoutingFormResponseOutputData)
|
||||
data!: CreateRoutingFormResponseOutputData;
|
||||
}
|
||||
}
|
||||
|
||||
+15
-198
@@ -1,13 +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";
|
||||
|
||||
@@ -16,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(
|
||||
@@ -46,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,
|
||||
@@ -63,194 +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];
|
||||
}
|
||||
}
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
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);
|
||||
|
||||
// Get available slots using the slots service with CRM parameters
|
||||
const slots = await this.slotsService.getAvailableSlotsWithRouting({
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
...slotsQuery,
|
||||
...crmParams,
|
||||
});
|
||||
|
||||
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];
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ export type OAuthCallbackState = {
|
||||
path: "/v2/organizations/:orgId/teams/:teamId/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Stripe")
|
||||
@DocsTags("Orgs / Teams / Stripe")
|
||||
export class OrganizationsStripeController {
|
||||
constructor(
|
||||
private readonly organizationsStripeService: OrganizationsStripeService,
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ export class OrganizationsTeamsBookingsController {
|
||||
IsAdminAPIEnabledGuard
|
||||
)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Booking References' for a booking",
|
||||
summary: "Get booking references",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getBookingReferences(
|
||||
|
||||
+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")
|
||||
|
||||
+12
-1
@@ -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";
|
||||
@@ -20,10 +22,18 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
|
||||
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04, TeamsEventTypesModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
RedisModule,
|
||||
RoutingFormsModule,
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsEventTypesModule,
|
||||
],
|
||||
providers: [
|
||||
OrganizationsTeamsRoutingFormsService,
|
||||
OrganizationsTeamsRoutingFormsResponsesService,
|
||||
SharedRoutingFormResponseService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
OrganizationsTeamsRoutingFormsResponsesRepository,
|
||||
OrganizationsTeamsRoutingFormsRepository,
|
||||
@@ -32,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,
|
||||
|
||||
+10
-10
@@ -52,8 +52,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class OrgTeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the email`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Throttle({
|
||||
@@ -82,8 +82,8 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -108,7 +108,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for an org team.",
|
||||
summary: "Verify an email for an org team",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -134,7 +134,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
summary: "Verify a phone number for an org team",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -166,7 +166,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of an org team.",
|
||||
summary: "Get list of verified emails of an org team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -189,7 +189,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of an org team.",
|
||||
summary: "Get list of verified phone numbers of an org team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@@ -214,7 +214,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of an org team by id.",
|
||||
summary: "Get verified email of an org team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -233,7 +233,7 @@ export class OrgTeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of an org team by id.",
|
||||
summary: "Get verified phone number of an org team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
|
||||
+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(
|
||||
|
||||
@@ -25,7 +25,6 @@ export class RouterController {
|
||||
@Param("formId") formId: string,
|
||||
@Body() body?: Record<string, string>
|
||||
): Promise<void | (ApiResponse<unknown> & { redirect: boolean })> {
|
||||
|
||||
const params = Object.fromEntries(new URLSearchParams(body ?? {}));
|
||||
const routedUrlData = await getRoutedUrl({ req: request, query: { ...params, form: formId } });
|
||||
if (routedUrlData?.notFound) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
@@ -17,7 +18,6 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
|
||||
|
||||
@@ -31,7 +31,8 @@ export class SlotsController_2024_04_15 {
|
||||
private readonly slotsService: SlotsService_2024_04_15,
|
||||
private readonly config: ConfigService,
|
||||
private readonly slotsOutputService: SlotsOutputService_2024_04_15,
|
||||
private readonly slotsWorkerService: SlotsWorkerService_2024_04_15
|
||||
private readonly slotsWorkerService: SlotsWorkerService_2024_04_15,
|
||||
private readonly availableSlotsService: AvailableSlotsService
|
||||
) {}
|
||||
|
||||
@Post("/reserve")
|
||||
@@ -170,8 +171,10 @@ export class SlotsController_2024_04_15 {
|
||||
: query.isTeamEvent;
|
||||
|
||||
// Do not use workers in E2E, not supported by TS-JEST
|
||||
const availableSlots = this.config.get<boolean>("e2e")
|
||||
? await getAvailableSlots({
|
||||
const availableSlotsService = this.config.get<boolean>("e2e") ? this.availableSlotsService : null;
|
||||
|
||||
const availableSlots = availableSlotsService
|
||||
? await availableSlotsService.getAvailableSlots({
|
||||
input: {
|
||||
...query,
|
||||
isTeamEvent,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { SlotsController_2024_04_15 } from "@/modules/slots/slots-2024-04-15/controllers/slots.controller";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
@@ -8,7 +9,7 @@ import { SlotsRepository_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slo
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_04_15],
|
||||
imports: [PrismaModule, EventTypesModule_2024_04_15, AvailableSlotsModule],
|
||||
providers: [
|
||||
SlotsRepository_2024_04_15,
|
||||
SlotsService_2024_04_15,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// apps/api/v2/src/modules/slots/slots-2024-04-15/workers/slots-worker.module.ts
|
||||
// Needed if ConfigService is used anywhere
|
||||
import appConfig from "@/config/app";
|
||||
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { Module, Logger } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { SentryModule } from "@sentry/nestjs/setup";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SentryModule.forRoot(),
|
||||
ConfigModule.forRoot({
|
||||
ignoreEnvFile: true,
|
||||
isGlobal: true,
|
||||
load: [appConfig],
|
||||
}),
|
||||
PrismaModule,
|
||||
AvailableSlotsModule,
|
||||
],
|
||||
providers: [Logger],
|
||||
})
|
||||
export class SlotsWorkerModule {}
|
||||
@@ -1,16 +1,65 @@
|
||||
import { parentPort } from "worker_threads";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { IncomingMessage } from "http";
|
||||
import { parentPort, isMainThread } from "worker_threads";
|
||||
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { SlotsWorkerModule } from "./slots.worker.module";
|
||||
|
||||
parentPort?.on("message", async (data) => {
|
||||
try {
|
||||
const { input, ctx } = data;
|
||||
const result = await getAvailableSlots({ input, ctx });
|
||||
parentPort?.postMessage({ success: true, data: result });
|
||||
} catch (error) {
|
||||
parentPort?.postMessage({
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
});
|
||||
interface ContextForGetSchedule extends Record<string, unknown> {
|
||||
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
|
||||
}
|
||||
|
||||
interface WorkerMessage {
|
||||
input: any; // Use a more specific type if available, e.g., TGetScheduleInputSchema
|
||||
ctx?: ContextForGetSchedule;
|
||||
}
|
||||
|
||||
interface WorkerResult {
|
||||
success: boolean;
|
||||
data?: any; // Use TimeSlots
|
||||
error?: {
|
||||
message: string;
|
||||
code?: string;
|
||||
stack?: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function bootstrapWorkerApp() {
|
||||
if (isMainThread) {
|
||||
console.error("slots-worker-main.ts should only be run in a worker thread.");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const app = await NestFactory.createApplicationContext(SlotsWorkerModule);
|
||||
const logger = app.get(Logger); // Get the NestJS Logger instance
|
||||
logger.log("NestJS worker application bootstrapped successfully.");
|
||||
|
||||
const availableSlotsService = app.get(AvailableSlotsService);
|
||||
|
||||
parentPort?.on("message", async (data: WorkerMessage) => {
|
||||
try {
|
||||
const { input, ctx } = data;
|
||||
|
||||
const result = await availableSlotsService.getAvailableSlots({ input, ctx });
|
||||
parentPort?.postMessage({ success: true, data: result } as WorkerResult);
|
||||
} catch (error: any) {
|
||||
parentPort?.postMessage({
|
||||
success: false,
|
||||
error: error instanceof Error ? error : new Error("Unknown error"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
parentPort?.on("close", async () => {
|
||||
logger.log("Worker port closed. Shutting down NestJS worker application.");
|
||||
await app.close();
|
||||
});
|
||||
|
||||
parentPort?.on("disconnect", async () => {
|
||||
logger.log("Worker port disconnected. Shutting down NestJS worker application.");
|
||||
await app.close();
|
||||
});
|
||||
}
|
||||
|
||||
bootstrapWorkerApp();
|
||||
|
||||
@@ -13,11 +13,32 @@ import {
|
||||
ByUsernameAndEventTypeSlug_2024_09_04,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
||||
GetSlotsInput_2024_09_04,
|
||||
GetSlotsInputWithRouting_2024_09_04,
|
||||
ById_2024_09_04_type,
|
||||
ByUsernameAndEventTypeSlug_2024_09_04_type,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04_type,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
export type InternalGetSlotsQuery = {
|
||||
isTeamEvent: boolean;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration?: number;
|
||||
eventTypeId: number;
|
||||
eventTypeSlug: string;
|
||||
usernameList: string[];
|
||||
timeZone: string | undefined;
|
||||
orgSlug: string | null | undefined;
|
||||
rescheduleUid: string | null;
|
||||
};
|
||||
|
||||
export type InternalGetSlotsQueryWithRouting = InternalGetSlotsQuery & {
|
||||
routedTeamMemberIds: number[] | null;
|
||||
skipContactOwner: boolean;
|
||||
teamMemberEmail: string | null;
|
||||
routingFormResponseId: number | undefined;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SlotsInputService_2024_09_04 {
|
||||
constructor(
|
||||
@@ -30,7 +51,7 @@ export class SlotsInputService_2024_09_04 {
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04) {
|
||||
async transformGetSlotsQuery(query: GetSlotsInput_2024_09_04): Promise<InternalGetSlotsQuery> {
|
||||
const eventType = await this.getEventType(query);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event Type not found`);
|
||||
@@ -46,10 +67,6 @@ export class SlotsInputService_2024_09_04 {
|
||||
const timeZone = query.timeZone;
|
||||
const orgSlug = "organizationSlug" in query ? query.organizationSlug : null;
|
||||
const rescheduleUid = query.bookingUidToReschedule || null;
|
||||
const routedTeamMemberIds = query.routedTeamMemberIds || null;
|
||||
const skipContactOwner = query.skipContactOwner || false;
|
||||
const teamMemberEmail = query.teamMemberEmail || null;
|
||||
const routingFormResponseId = query.routingFormResponseId || null;
|
||||
|
||||
return {
|
||||
isTeamEvent,
|
||||
@@ -62,10 +79,23 @@ export class SlotsInputService_2024_09_04 {
|
||||
timeZone,
|
||||
orgSlug,
|
||||
rescheduleUid,
|
||||
routedTeamMemberIds,
|
||||
skipContactOwner,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
};
|
||||
}
|
||||
|
||||
async transformRoutingGetSlotsQuery(
|
||||
query: GetSlotsInputWithRouting_2024_09_04
|
||||
): Promise<InternalGetSlotsQueryWithRouting> {
|
||||
const { routedTeamMemberIds, skipContactOwner, teamMemberEmail, routingFormResponseId, ...baseQuery } =
|
||||
query;
|
||||
|
||||
const baseTransformation = await this.transformGetSlotsQuery(baseQuery);
|
||||
|
||||
return {
|
||||
...baseTransformation,
|
||||
routedTeamMemberIds: routedTeamMemberIds || null,
|
||||
skipContactOwner: skipContactOwner || false,
|
||||
teamMemberEmail: teamMemberEmail || null,
|
||||
routingFormResponseId: routingFormResponseId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
|
||||
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
|
||||
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { SlotsService_2024_09_04 } from "./slots.service";
|
||||
|
||||
describe("SlotsService_2024_09_04", () => {
|
||||
let service: SlotsService_2024_09_04;
|
||||
let eventTypesRepository: EventTypesRepository_2024_06_14;
|
||||
let availableSlotsService: AvailableSlotsService;
|
||||
let slotsInputService: SlotsInputService_2024_09_04;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SlotsService_2024_09_04,
|
||||
{
|
||||
provide: SlotsInputService_2024_09_04,
|
||||
useValue: {
|
||||
transformGetSlotsQuery: jest.fn(),
|
||||
transformRoutingGetSlotsQuery: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EventTypesRepository_2024_06_14,
|
||||
useValue: {
|
||||
getEventTypeById: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: SlotsRepository_2024_09_04,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SlotsOutputService_2024_09_04,
|
||||
useValue: {
|
||||
getAvailableSlots: jest.fn().mockResolvedValue({
|
||||
slots: {
|
||||
"2024-01-15": [{ time: "2024-01-15T10:00:00.000Z" }, { time: "2024-01-15T11:00:00.000Z" }],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: AvailableSlotsService,
|
||||
useValue: {
|
||||
getAvailableSlots: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MembershipsService,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: MembershipsRepository,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: TeamsRepository,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SlotsService_2024_09_04>(SlotsService_2024_09_04);
|
||||
slotsInputService = module.get<SlotsInputService_2024_09_04>(SlotsInputService_2024_09_04);
|
||||
eventTypesRepository = module.get<EventTypesRepository_2024_06_14>(EventTypesRepository_2024_06_14);
|
||||
availableSlotsService = module.get<AvailableSlotsService>(AvailableSlotsService);
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getAvailableSlotsWithRouting", () => {
|
||||
const sharedTestData = {
|
||||
eventTypeId: 123,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
mockEventType: {
|
||||
id: 123,
|
||||
slug: "test-event",
|
||||
teamId: null, // Not a team event
|
||||
length: 30,
|
||||
},
|
||||
mockSlotsResponse: {
|
||||
slots: {},
|
||||
},
|
||||
baseInputQuery: {
|
||||
type: "byEventTypeId" as const,
|
||||
isTeamEvent: false,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
duration: undefined,
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
},
|
||||
routingParams: {
|
||||
routedTeamMemberIds: [456, 789],
|
||||
skipContactOwner: true,
|
||||
teamMemberEmail: "test@example.com",
|
||||
routingFormResponseId: 999,
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup shared mocks
|
||||
(eventTypesRepository.getEventTypeById as jest.Mock).mockResolvedValue(sharedTestData.mockEventType);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue(
|
||||
sharedTestData.mockSlotsResponse
|
||||
);
|
||||
});
|
||||
|
||||
it("should call getAvailableSlots with correct routing parameters", async () => {
|
||||
const inputQuery = {
|
||||
...sharedTestData.baseInputQuery,
|
||||
...sharedTestData.routingParams,
|
||||
};
|
||||
|
||||
// Mock the transform method to return the expected transformed query
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
...sharedTestData.routingParams,
|
||||
};
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
|
||||
await service.getAvailableSlotsWithRouting({
|
||||
...inputQuery,
|
||||
});
|
||||
|
||||
const { start: _1, end: _2, type: _3, ...queryWithoutStartEndAndType } = sharedTestData.baseInputQuery;
|
||||
|
||||
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
|
||||
input: {
|
||||
...queryWithoutStartEndAndType,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
...sharedTestData.routingParams,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableSlotsWithRouting - Error Scenarios", () => {
|
||||
const baseInputQuery = {
|
||||
type: "byEventTypeId" as const,
|
||||
eventTypeId: 123,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
|
||||
it("should handle when event type is not found", async () => {
|
||||
const notFoundError = new NotFoundException("Event Type not found");
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockRejectedValue(notFoundError);
|
||||
|
||||
await expect(
|
||||
service.getAvailableSlotsWithRouting({
|
||||
...baseInputQuery,
|
||||
teamMemberEmail: "test@example.com",
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
expect(slotsInputService.transformRoutingGetSlotsQuery).toHaveBeenCalledWith({
|
||||
...baseInputQuery,
|
||||
teamMemberEmail: "test@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invalid time range error and throw BadRequestException", async () => {
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-16T00:00:00.000Z",
|
||||
endTime: "2024-01-15T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
routedTeamMemberIds: null,
|
||||
skipContactOwner: false,
|
||||
teamMemberEmail: null,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(
|
||||
new Error("Invalid time range given - start time must be before end time")
|
||||
);
|
||||
|
||||
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(BadRequestException);
|
||||
|
||||
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(
|
||||
"Invalid time range given - check the 'start' and 'end' query parameters."
|
||||
);
|
||||
});
|
||||
|
||||
it("should re-throw non-time-range errors without modification", async () => {
|
||||
const genericError = new Error("Database connection failed");
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
routedTeamMemberIds: null,
|
||||
skipContactOwner: false,
|
||||
teamMemberEmail: null,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(genericError);
|
||||
|
||||
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toThrow(genericError);
|
||||
});
|
||||
|
||||
it("should handle non-Error objects thrown by dependencies", async () => {
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
routedTeamMemberIds: null,
|
||||
skipContactOwner: false,
|
||||
teamMemberEmail: null,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
const nonErrorObject = { message: "Something went wrong", code: "UNKNOWN_ERROR" };
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockRejectedValue(nonErrorObject);
|
||||
|
||||
await expect(service.getAvailableSlotsWithRouting(baseInputQuery)).rejects.toEqual(nonErrorObject);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAvailableSlotsWithRouting - Edge Cases", () => {
|
||||
it("should handle null and undefined routing parameters", async () => {
|
||||
const input = {
|
||||
type: "byEventTypeId" as const,
|
||||
eventTypeId: 123,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
teamMemberEmail: undefined,
|
||||
routedTeamMemberIds: undefined,
|
||||
skipContactOwner: undefined,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
teamMemberEmail: null,
|
||||
routedTeamMemberIds: null,
|
||||
skipContactOwner: false,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
|
||||
|
||||
await service.getAvailableSlotsWithRouting(input);
|
||||
|
||||
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
|
||||
input: expect.objectContaining({
|
||||
teamMemberEmail: null,
|
||||
routedTeamMemberIds: null,
|
||||
skipContactOwner: false,
|
||||
routingFormResponseId: undefined,
|
||||
}),
|
||||
ctx: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle empty routedTeamMemberIds array", async () => {
|
||||
const input = {
|
||||
type: "byEventTypeId" as const,
|
||||
eventTypeId: 123,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
routedTeamMemberIds: [],
|
||||
};
|
||||
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
routedTeamMemberIds: [],
|
||||
skipContactOwner: false,
|
||||
teamMemberEmail: null,
|
||||
routingFormResponseId: undefined,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
|
||||
|
||||
await service.getAvailableSlotsWithRouting(input);
|
||||
|
||||
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
|
||||
input: expect.objectContaining({
|
||||
routedTeamMemberIds: [],
|
||||
}),
|
||||
ctx: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle all routing parameters being provided", async () => {
|
||||
const input = {
|
||||
type: "byEventTypeId" as const,
|
||||
eventTypeId: 123,
|
||||
start: "2024-01-15T00:00:00.000Z",
|
||||
end: "2024-01-16T00:00:00.000Z",
|
||||
timeZone: "America/New_York",
|
||||
routedTeamMemberIds: [1, 2, 3],
|
||||
skipContactOwner: true,
|
||||
teamMemberEmail: "team@example.com",
|
||||
routingFormResponseId: 999,
|
||||
};
|
||||
|
||||
const transformedQuery = {
|
||||
isTeamEvent: false,
|
||||
startTime: "2024-01-15T00:00:00.000Z",
|
||||
endTime: "2024-01-16T23:59:59.000Z",
|
||||
eventTypeId: 123,
|
||||
eventTypeSlug: "test-event",
|
||||
usernameList: [],
|
||||
timeZone: "America/New_York",
|
||||
orgSlug: null,
|
||||
rescheduleUid: null,
|
||||
routedTeamMemberIds: [1, 2, 3],
|
||||
skipContactOwner: true,
|
||||
teamMemberEmail: "team@example.com",
|
||||
routingFormResponseId: 999,
|
||||
};
|
||||
|
||||
(slotsInputService.transformRoutingGetSlotsQuery as jest.Mock).mockResolvedValue(transformedQuery);
|
||||
(availableSlotsService.getAvailableSlots as jest.Mock).mockResolvedValue({ slots: {} });
|
||||
|
||||
await service.getAvailableSlotsWithRouting(input);
|
||||
|
||||
expect(availableSlotsService.getAvailableSlots).toHaveBeenCalledWith({
|
||||
input: expect.objectContaining({
|
||||
routedTeamMemberIds: [1, 2, 3],
|
||||
skipContactOwner: true,
|
||||
teamMemberEmail: "team@example.com",
|
||||
routingFormResponseId: 999,
|
||||
}),
|
||||
ctx: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,13 @@
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
|
||||
import { TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsInputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
|
||||
import {
|
||||
SlotsInputService_2024_09_04,
|
||||
InternalGetSlotsQuery,
|
||||
InternalGetSlotsQueryWithRouting,
|
||||
} from "@/modules/slots/slots-2024-09-04/services/slots-input.service";
|
||||
import { SlotsOutputService_2024_09_04 } from "@/modules/slots/slots-2024-09-04/services/slots-output.service";
|
||||
import { SlotsRepository_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
@@ -17,9 +22,13 @@ import {
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { GetSlotsInput_2024_09_04, ReserveSlotInput_2024_09_04 } from "@calcom/platform-types";
|
||||
import { Booking, EventType } from "@calcom/prisma/client";
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
import {
|
||||
GetSlotsInput_2024_09_04,
|
||||
GetSlotsInputWithRouting_2024_09_04,
|
||||
ReserveSlotInput_2024_09_04,
|
||||
} from "@calcom/platform-types";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
const eventTypeMetadataSchema = z
|
||||
.object({
|
||||
@@ -29,6 +38,7 @@ const eventTypeMetadataSchema = z
|
||||
|
||||
const DEFAULT_RESERVATION_DURATION = 5;
|
||||
|
||||
type InternalSlotsQuery = InternalGetSlotsQuery | InternalGetSlotsQueryWithRouting;
|
||||
@Injectable()
|
||||
export class SlotsService_2024_09_04 {
|
||||
constructor(
|
||||
@@ -38,24 +48,22 @@ export class SlotsService_2024_09_04 {
|
||||
private readonly slotsInputService: SlotsInputService_2024_09_04,
|
||||
private readonly membershipsService: MembershipsService,
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly teamsRepository: TeamsRepository
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly availableSlotsService: AvailableSlotsService
|
||||
) {}
|
||||
|
||||
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
|
||||
private async fetchAndFormatSlots(queryTransformed: InternalSlotsQuery, format?: SlotFormat) {
|
||||
try {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
|
||||
},
|
||||
const availableSlots: TimeSlots = await this.availableSlotsService.getAvailableSlots({
|
||||
input: queryTransformed,
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
const formatted = await this.slotsOutputService.getAvailableSlots(
|
||||
availableSlots,
|
||||
queryTransformed.eventTypeId,
|
||||
queryTransformed.duration,
|
||||
query.format,
|
||||
format,
|
||||
queryTransformed.timeZone
|
||||
);
|
||||
|
||||
@@ -72,6 +80,16 @@ export class SlotsService_2024_09_04 {
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableSlots(query: GetSlotsInput_2024_09_04) {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
return this.fetchAndFormatSlots(queryTransformed, query.format);
|
||||
}
|
||||
|
||||
async getAvailableSlotsWithRouting(query: GetSlotsInputWithRouting_2024_09_04) {
|
||||
const queryTransformed = await this.slotsInputService.transformRoutingGetSlotsQuery(query);
|
||||
return this.fetchAndFormatSlots(queryTransformed, query.format);
|
||||
}
|
||||
|
||||
async reserveSlot(input: ReserveSlotInput_2024_09_04, authUserId?: number) {
|
||||
if (input.reservationDuration && !authUserId) {
|
||||
throw new UnauthorizedException(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AvailableSlotsModule } from "@/lib/modules/available-slots.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
@@ -23,6 +24,7 @@ import { Module } from "@nestjs/common";
|
||||
TeamsModule,
|
||||
MembershipsModule,
|
||||
TeamsEventTypesModule,
|
||||
AvailableSlotsModule,
|
||||
],
|
||||
providers: [
|
||||
SlotsRepository_2024_09_04,
|
||||
|
||||
+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);
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -47,8 +47,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class TeamsVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the Email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the Email`,
|
||||
})
|
||||
@Roles("TEAM_ADMIN")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -71,8 +71,8 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -92,7 +92,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email for a team.",
|
||||
summary: "Verify an email for a team",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -117,7 +117,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number for an org team.",
|
||||
summary: "Verify a phone number for an org team",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -143,7 +143,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails of a team.",
|
||||
summary: "Get list of verified emails of a team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -165,7 +165,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers of a team.",
|
||||
summary: "Get list of verified phone numbers of a team",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@@ -189,7 +189,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email of a team by id.",
|
||||
summary: "Get verified email of a team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@@ -207,7 +207,7 @@ export class TeamsVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number of a team by id.",
|
||||
summary: "Get verified phone number of a team by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Roles("TEAM_ADMIN")
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [TokensRepository],
|
||||
exports: [TokensRepository],
|
||||
imports: [PrismaModule, JwtModule],
|
||||
providers: [TokensRepository, TokensService],
|
||||
exports: [TokensRepository, TokensService],
|
||||
})
|
||||
export class TokensModule {}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, InternalServerErrorException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
|
||||
type OAuthTokenPayload = {
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
scope: string[];
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class TokensService {
|
||||
private readonly logger = new Logger("TokensService");
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
getDecodedThirdPartyAccessToken(token: string): OAuthTokenPayload | null {
|
||||
const encryptionKey = this.config.get<string>("CALENDSO_ENCRYPTION_KEY");
|
||||
if (!encryptionKey) {
|
||||
throw new InternalServerErrorException("CALENDSO_ENCRYPTION_KEY environment variable is not set.");
|
||||
}
|
||||
|
||||
let decodedToken: OAuthTokenPayload;
|
||||
try {
|
||||
decodedToken = jwt.verify(token, encryptionKey) as OAuthTokenPayload;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!decodedToken || decodedToken.token_type !== "Access Token") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return decodedToken;
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,27 @@ export class UsersRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findOwnerByTeamIdWithProfile(teamId: number): Promise<UserWithProfile | null> {
|
||||
return this.dbRead.prisma.user.findFirst({
|
||||
where: {
|
||||
teams: {
|
||||
some: {
|
||||
teamId,
|
||||
role: "OWNER",
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
movedToProfile: {
|
||||
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
|
||||
},
|
||||
profiles: {
|
||||
include: { organization: { select: { isPlatform: true, name: true, slug: true, id: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdsWithEventTypes(userIds: number[]) {
|
||||
return this.dbRead.prisma.user.findMany({
|
||||
where: {
|
||||
|
||||
@@ -26,6 +26,7 @@ export class LocaleValidator implements ValidatorConstraintInterface {
|
||||
"sr",
|
||||
"sv",
|
||||
"vi",
|
||||
"bn",
|
||||
];
|
||||
|
||||
if (localeValues.includes(locale)) return true;
|
||||
|
||||
+10
-10
@@ -34,8 +34,8 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
export class UserVerifiedResourcesController {
|
||||
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
|
||||
@ApiOperation({
|
||||
summary: "Request Email Verification Code",
|
||||
description: `Sends a verification code to the email.`,
|
||||
summary: "Request email verification code",
|
||||
description: `Sends a verification code to the email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/emails/verification-code/request")
|
||||
@@ -57,8 +57,8 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Request Phone Number Verification Code",
|
||||
description: `Sends a verification code to the phone number.`,
|
||||
summary: "Request phone number verification code",
|
||||
description: `Sends a verification code to the phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/phones/verification-code/request")
|
||||
@@ -77,7 +77,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify an email.",
|
||||
summary: "Verify an email",
|
||||
description: `Use code to verify an email`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -96,7 +96,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Verify a phone number.",
|
||||
summary: "Verify a phone number",
|
||||
description: `Use code to verify a phone number`,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@@ -114,7 +114,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified emails.",
|
||||
summary: "Get list of verified emails",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails")
|
||||
@@ -135,7 +135,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get list of verified phone numbers.",
|
||||
summary: "Get list of verified phone numbers",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones")
|
||||
@@ -158,7 +158,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified email by id.",
|
||||
summary: "Get verified email by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/emails/:id")
|
||||
@@ -175,7 +175,7 @@ export class UserVerifiedResourcesController {
|
||||
}
|
||||
|
||||
@ApiOperation({
|
||||
summary: "Get verified phone number by id.",
|
||||
summary: "Get verified phone number by id",
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/phones/:id")
|
||||
|
||||
@@ -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." })
|
||||
|
||||
+2034
-366
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",
|
||||
};
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
"@calcom/platform-libraries/schedules": ["../../../packages/platform/libraries/schedules.ts"],
|
||||
"@calcom/platform-libraries/app-store": ["../../../packages/platform/libraries/app-store.ts"],
|
||||
"@calcom/platform-libraries/workflows": ["../../../packages/platform/libraries/workflows.ts"],
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"]
|
||||
"@calcom/platform-libraries/conferencing": ["../../../packages/platform/libraries/conferencing.ts"],
|
||||
"@calcom/platform-libraries/repositories": ["../../../packages/platform/libraries/repositories.ts"]
|
||||
},
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"eslint": "^9",
|
||||
"eslint": "^8.34.0",
|
||||
"eslint-config-next": "15.1.6",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
|
||||
@@ -11,6 +11,15 @@ import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps
|
||||
|
||||
import TypePage, { type PageProps as ClientPageProps } from "~/users/views/users-type-public-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return {
|
||||
robots: {
|
||||
follow: false,
|
||||
index: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
|
||||
@@ -8,6 +8,15 @@ import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
|
||||
|
||||
import User, { type PageProps as ClientPageProps } from "~/users/views/users-public-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return {
|
||||
robots: {
|
||||
follow: false,
|
||||
index: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const getData = withEmbedSsrAppDir<ClientPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user