Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bfc4f7b66c | ||
|
|
9dca13305f | ||
|
|
0ab9823c9a | ||
|
|
b3fd4f307f | ||
|
|
06ed0de456 | ||
|
|
79b6278883 | ||
|
|
673823cb97 | ||
|
|
15b26dff73 | ||
|
|
2303f2e9e1 | ||
|
|
056b821070 | ||
|
|
c90fded6b7 | ||
|
|
aa6f1a7f93 | ||
|
|
0cdd7c9605 | ||
|
|
f40a1c92ef | ||
|
|
739ef153f6 | ||
|
|
850ac8092f | ||
|
|
5b1eb48e6b | ||
|
|
da6fc9f2d5 | ||
|
|
20434856e9 | ||
|
|
7f891adff2 | ||
|
|
eb66fbe054 | ||
|
|
29122934a9 | ||
|
|
0825cce82c | ||
|
|
fd964131d1 | ||
|
|
77d9c15fed | ||
|
|
b21295f268 | ||
|
|
5769553659 | ||
|
|
d23d68bf2d | ||
|
|
d04c7f58a1 | ||
|
|
93e2426760 | ||
|
|
9f6c05f389 | ||
|
|
f8a1bd7b94 | ||
|
|
02967961f3 | ||
|
|
053b8b5c0a | ||
|
|
d59efd3ba0 | ||
|
|
ce56cd1ab0 | ||
|
|
54b6deee19 | ||
|
|
8315641724 | ||
|
|
aa3086cfae | ||
|
|
b599ce4919 | ||
|
|
52f16604c1 | ||
|
|
c98c06062b | ||
|
|
4963d247dc | ||
|
|
9405bc9553 | ||
|
|
8a080b91b9 | ||
|
|
4aa05fe523 | ||
|
|
d74d99dde7 | ||
|
|
0ee137efbb |
@@ -164,6 +164,7 @@ SENTRY_PROJECT=
|
||||
SENTRY_AUTH_TOKEN=
|
||||
# Used for capturing exceptions and logging messages
|
||||
NEXT_PUBLIC_SENTRY_DSN=
|
||||
NEXT_PUBLIC_SENTRY_DSN_CLIENT=
|
||||
SENTRY_DEBUG=
|
||||
SENTRY_MAX_SPANS=
|
||||
SENTRY_SAMPLE_RATE=
|
||||
|
||||
@@ -27,8 +27,24 @@ jobs:
|
||||
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --local user.email "github-actions@github.com"
|
||||
git config --local user.name "GitHub Actions"
|
||||
# Define authorized users and their emails using organization secret
|
||||
# Expected format: JSON object with username -> email mapping
|
||||
# Example: {"zomars": "zomars@cal.com", "peer": "peer@cal.com", "username3": "email3@cal.com"}
|
||||
USER_EMAILS='${{ secrets.RELEASE_USER_EMAILS }}'
|
||||
|
||||
# Extract email for the triggering user
|
||||
USER_EMAIL=$(echo "$USER_EMAILS" | jq -r --arg user "${{ github.actor }}" '.[$user] // empty')
|
||||
|
||||
# Fail if user is not authorized or not in mapping
|
||||
if [ -z "$USER_EMAIL" ] || [ "$USER_EMAIL" = "null" ]; then
|
||||
echo "Error: User '${{ github.actor }}' is not authorized to run the release workflow."
|
||||
echo "Only authorized team members can trigger releases."
|
||||
echo "Contact your administrator to be added to the authorized users list."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git config --local user.email "$USER_EMAIL"
|
||||
git config --local user.name "${{ github.actor }}"
|
||||
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
with:
|
||||
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
|
||||
organization-name: calcom
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, devops, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
|
||||
apply-labels-from-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || (github.event.action == 'labeled' && github.event.label.name == 'ready-for-e2e')) }}
|
||||
run-e2e: ${{ steps.check-if-pr-has-label.outputs.run-e2e == 'true' && (github.event.action != 'labeled' || github.event.label.name == 'ready-for-e2e') }}
|
||||
steps:
|
||||
- name: Check if PR exists with ready-for-e2e label for this SHA
|
||||
id: check-if-pr-has-label
|
||||
|
||||
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
@@ -488,13 +488,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",
|
||||
|
||||
Vendored
+1
-1
@@ -2,4 +2,4 @@
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -5,9 +5,6 @@ const plugins = [withAxiom];
|
||||
|
||||
/** @type {import("next").NextConfig} */
|
||||
const nextConfig = {
|
||||
experimental: {
|
||||
instrumentationHook: true,
|
||||
},
|
||||
transpilePackages: [
|
||||
"@calcom/app-store",
|
||||
"@calcom/dayjs",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^9.15.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.9",
|
||||
"next": "^15.3.0",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -93,6 +93,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
@@ -136,6 +138,8 @@ describe("POST /api/selected-calendars", () => {
|
||||
watchAttempts: 0,
|
||||
maxAttempts: 3,
|
||||
unwatchAttempts: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.236",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.257",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -232,6 +232,15 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
|
||||
if (bookingUid) {
|
||||
const { bookingInfo } = await getBookingInfo(bookingUid);
|
||||
if (!bookingInfo) {
|
||||
throw new NotFoundException(`Booking with UID=${bookingUid} does not exist.`);
|
||||
}
|
||||
if (bookingInfo.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2553,6 +2552,59 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("cant't cancel already cancelled booking", () => {
|
||||
it("should not be able to cancel alraedy cancelled booking", async () => {
|
||||
const cancelledBooking = await bookingsRepositoryFixture.create({
|
||||
status: "CANCELLED",
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2050, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2050, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `cancelled-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeId,
|
||||
},
|
||||
},
|
||||
location: "integrations:daily",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
responses: {
|
||||
name: "Oldie",
|
||||
email: "oldie@gmail.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "oldie@gmail.com",
|
||||
name: "Oldie",
|
||||
locale: "lv",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
cancellationReason: "Going on a vacation",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${cancelledBooking.uid}/cancel`)
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.error.message).toEqual(
|
||||
`Can't cancel booking with uid=${cancelledBooking.uid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
await bookingsRepositoryFixture.deleteById(cancelledBooking.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("calendar events", () => {
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,6 +186,31 @@ export class InputBookingsService_2024_08_13 {
|
||||
guests,
|
||||
location,
|
||||
},
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
teamMemberEmail: routing.teamMemberEmail,
|
||||
skipContactOwner: routing.skipContactOwner,
|
||||
crmAppSlug: routing.crmAppSlug,
|
||||
crmOwnerRecordType: routing.crmOwnerRecordType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -453,6 +478,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
location,
|
||||
},
|
||||
schedulingType: eventType.schedulingType,
|
||||
...this.getRoutingFormData(inputBooking.routing),
|
||||
});
|
||||
|
||||
switch (timeBetween) {
|
||||
@@ -711,6 +737,12 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
|
||||
if (booking.status === "CANCELLED") {
|
||||
throw new BadRequestException(
|
||||
`Can't cancel booking with uid=${bookingUid} because it has been cancelled already. Please provide uid of a booking that is not cancelled.`
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
@@ -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,9 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
const credentialForCalendarRepositorySelect = {
|
||||
id: true,
|
||||
appId: true,
|
||||
type: true,
|
||||
@@ -16,7 +16,7 @@ const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.Credential
|
||||
teamId: true,
|
||||
key: true,
|
||||
invalid: true,
|
||||
});
|
||||
} satisfies Prisma.CredentialSelect;
|
||||
|
||||
@Injectable()
|
||||
export class CalendarsRepository {
|
||||
|
||||
@@ -157,7 +157,7 @@ export class CalendarsController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get oAuth connect URL" })
|
||||
@ApiOperation({ summary: "Get OAuth connect URL" })
|
||||
@ApiQuery({
|
||||
name: "redir",
|
||||
required: false,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
|
||||
+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,7 +3,7 @@ import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesRepository_2024_04_15 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Team } from "@calcom/prisma/client";
|
||||
import { ApiAuthGuardUser } from "../modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
|
||||
export interface UserContext {
|
||||
userId?: number;
|
||||
userId?: string;
|
||||
userEmail?: string;
|
||||
organizationId?: number;
|
||||
teamId?: number;
|
||||
organizationId?: string;
|
||||
teamId?: string;
|
||||
userOrgId?: string;
|
||||
}
|
||||
|
||||
export function extractUserContext(request: Request): UserContext {
|
||||
@@ -16,18 +17,21 @@ export function extractUserContext(request: Request): UserContext {
|
||||
|
||||
const user = (request as any).user as ApiAuthGuardUser | undefined;
|
||||
if (user) {
|
||||
context.userId = user.id;
|
||||
context.userId = String(user.id);
|
||||
context.userEmail = user.email;
|
||||
if (user.profiles?.[0]?.organizationId) {
|
||||
context.userOrgId = String(user.profiles?.[0].organizationId);
|
||||
}
|
||||
}
|
||||
|
||||
const organizationId = (request as any).organizationId as number | undefined;
|
||||
if (organizationId) {
|
||||
context.organizationId = organizationId;
|
||||
context.organizationId = String(organizationId);
|
||||
}
|
||||
|
||||
const team = (request as any).team as Team | undefined;
|
||||
if (team) {
|
||||
context.teamId = team.id;
|
||||
context.teamId = String(team.id);
|
||||
}
|
||||
|
||||
return context;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractUserContext } from "@/lib/extract-user-context";
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
|
||||
import { Request, Response } from "express";
|
||||
import { tap } from "rxjs/operators";
|
||||
@@ -14,6 +15,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
const { method, url } = request;
|
||||
const userContext = extractUserContext(request);
|
||||
const startTime = Date.now();
|
||||
|
||||
return next.handle().pipe(
|
||||
@@ -39,6 +41,7 @@ export class ResponseInterceptor implements NestInterceptor {
|
||||
responseTime,
|
||||
responseBody: jsonBodyString,
|
||||
timestamp: new Date().toISOString(),
|
||||
...userContext,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
@@ -95,7 +95,9 @@ 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);
|
||||
const onlyManagedTeamMembers = eventType.teamMembers.filter(
|
||||
(user) => user.isPlatformManaged && user.membership === "MEMBER"
|
||||
);
|
||||
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[]>();
|
||||
|
||||
@@ -10,16 +10,30 @@ import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable, InternalServerErrorException, UnauthorizedException } from "@nestjs/common";
|
||||
import {
|
||||
HttpException,
|
||||
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";
|
||||
import * as jwt from "jsonwebtoken";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { INVALID_ACCESS_TOKEN, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
import type { AllowedAuthMethod } from "../../decorators/api-auth-guard-only-allow.decorator";
|
||||
|
||||
interface OAuthTokenPayload {
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
scope: string[];
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export type ApiAuthGuardUser = UserWithProfile & { isSystemAdmin: boolean };
|
||||
export type ApiAuthGuardRequest = Request & {
|
||||
authMethod: AuthMethods;
|
||||
@@ -29,8 +43,11 @@ 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,
|
||||
@@ -54,22 +71,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 +131,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 +215,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 +318,55 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async validateThirdPartyAccessToken(
|
||||
token: string,
|
||||
request: ApiAuthGuardRequest
|
||||
): Promise<{ success: true; data: UserWithProfile } | { success: false }> {
|
||||
// Removed requiredScopes parameter
|
||||
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 { success: false };
|
||||
}
|
||||
|
||||
if (!decodedToken || decodedToken.token_type !== "Access Token") {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingSeatRepository {
|
||||
|
||||
+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(
|
||||
|
||||
@@ -5,7 +5,7 @@ import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { Injectable, UnauthorizedException } from "@nestjs/common";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { JWT } from "googleapis-common";
|
||||
|
||||
import { DelegationCredentialRepository, OAuth2UniversalSchema } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -87,7 +87,7 @@ export class ConferencingController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth URL" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
@@ -130,7 +130,7 @@ export class ConferencingController {
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "conferencing apps oauths callback" })
|
||||
@ApiOperation({ summary: "Conferencing app OAuth callback" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
export class ConferencingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
private readonly credentialSelect = {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
@@ -20,7 +20,7 @@ export class ConferencingRepository {
|
||||
billingCycleStart: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
} satisfies Prisma.CredentialSelect;
|
||||
|
||||
async findConferencingApps(userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
|
||||
+4
@@ -92,6 +92,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
publisher: "",
|
||||
url: "",
|
||||
email: "",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
// calendars: {
|
||||
// externalId:
|
||||
@@ -118,6 +120,8 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
id: 0,
|
||||
delegationCredentialId: null,
|
||||
domainWideDelegationCredentialId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { PrismaWriteService } from "../../prisma/prisma-write.service";
|
||||
|
||||
|
||||
+2
-2
@@ -134,7 +134,7 @@ export class OrganizationsAttributesOptionsController {
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/:attributeId/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute id all of the attribute options that are assigned to users" })
|
||||
@ApiOperation({ summary: "Get all assigned attribute options by attribute ID" })
|
||||
async getOrganizationAttributeAssignedOptions(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeId") attributeId: string,
|
||||
@@ -158,7 +158,7 @@ export class OrganizationsAttributesOptionsController {
|
||||
@Roles("ORG_MEMBER")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/attributes/slugs/:attributeSlug/options/assigned")
|
||||
@ApiOperation({ summary: "Get by attribute slug all of the attribute options that are assigned to users" })
|
||||
@ApiOperation({ summary: "Get all assigned attribute options by attribute slug" })
|
||||
async getOrganizationAttributeAssignedOptionsBySlug(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("attributeSlug") attributeSlug: string,
|
||||
|
||||
@@ -34,7 +34,7 @@ export class OrganizationsBookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get Organization Bookings" })
|
||||
@ApiOperation({ summary: "Get organization bookings" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
+1
-1
@@ -105,7 +105,7 @@ export class OrganizationsConferencingController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth URL for a team" })
|
||||
async getTeamOAuthUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
|
||||
+2
-2
@@ -52,7 +52,7 @@ export class OrganizationsDelegationCredentialController {
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@ApiOperation({ summary: "Save delegation credentials for your organization." })
|
||||
@ApiOperation({ summary: "Save delegation credentials for your organization" })
|
||||
async createDelegationCredential(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() delegatedServiceAccountUser: User,
|
||||
@@ -72,7 +72,7 @@ export class OrganizationsDelegationCredentialController {
|
||||
@Patch("/:credentialId")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("SCALE")
|
||||
@ApiOperation({ summary: "Update delegation credentials of your organization." })
|
||||
@ApiOperation({ summary: "Update delegation credentials of your organization" })
|
||||
async updateDelegationCredential(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@GetUser() delegatedServiceAccountUser: User,
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsDelegationCredentialRepository {
|
||||
|
||||
+12
-6
@@ -304,8 +304,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);
|
||||
@@ -341,13 +343,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);
|
||||
@@ -381,13 +385,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);
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
|
||||
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class ManagedOrganizationsRepository {
|
||||
|
||||
+446
-4
@@ -37,7 +37,13 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
let user: User;
|
||||
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let routingEventType: {
|
||||
id: number;
|
||||
slug: string | null;
|
||||
teamId: number | null;
|
||||
userId: number | null;
|
||||
title: string;
|
||||
};
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
@@ -72,6 +78,7 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
role: "OWNER",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: org.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
@@ -93,14 +100,74 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `${keyString}`;
|
||||
|
||||
// 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: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
description: "Test Description",
|
||||
disabled: false,
|
||||
routes: JSON.stringify([]),
|
||||
fields: JSON.stringify([]),
|
||||
settings: JSON.stringify({}),
|
||||
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/${team.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: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
@@ -175,6 +242,381 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(`POST /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
|
||||
it("should return 403 when organization does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/99999/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it("should return 404 when routing form does not exist", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should return 401 when authentication token is missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it("should create response and return available slots when routing to event type", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
expect(typeof data.slots).toBe("object");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when required form fields are missing", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question2: "answer2", // Missing required question1
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create response and return custom message if the routing is to custom page", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "different-answer", // This won't match any route
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routingCustomMessage).toBeDefined();
|
||||
expect(data.routingCustomMessage).toBe("Fallback Message");
|
||||
});
|
||||
});
|
||||
|
||||
it("should return 400 when required slot query parameters are missing", async () => {
|
||||
// Missing start parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?end=2050-09-06`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(400);
|
||||
|
||||
// Missing end parameter
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05`)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when date parameters have invalid format", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=invalid-date&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 400 when end date is before start date", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-10&end=2050-09-05`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should return 403 when user lacks permission to access organization", async () => {
|
||||
// Create a new user without organization access
|
||||
const unauthorizedUser = await userRepositoryFixture.create({
|
||||
email: `unauthorized-user-${randomString()}@api.com`,
|
||||
});
|
||||
|
||||
const { keyString: unauthorizedApiKey } = await apiKeysRepositoryFixture.createApiKey(
|
||||
unauthorizedUser.id,
|
||||
null
|
||||
);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${unauthorizedApiKey}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
|
||||
// Clean up
|
||||
await prismaWriteService.prisma.user.delete({
|
||||
where: { id: unauthorizedUser.id },
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle queued response creation", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06&queueResponse=true`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1",
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data.routing?.queuedResponseId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
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/${team.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: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Try to create a response for the form with invalid event type
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingFormWithInvalidEventType.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.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 handle routing with team member assignments", async () => {
|
||||
// This test verifies that routing forms can handle team member assignments
|
||||
// and that the routing returns the correct team member information
|
||||
return request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?start=2050-09-05&end=2050-09-06`
|
||||
)
|
||||
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
|
||||
.send({
|
||||
question1: "answer1", // This matches the route condition
|
||||
question2: "answer2",
|
||||
})
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
const data = responseBody.data;
|
||||
expect(data).toBeDefined();
|
||||
expect(data.routing?.responseId).toBeDefined();
|
||||
expect(typeof data.routing?.responseId).toBe("number");
|
||||
expect(data.eventTypeId).toEqual(routingEventType.id);
|
||||
expect(data.slots).toBeDefined();
|
||||
// Team member assignments would be part of the routing data
|
||||
// This test validates the basic routing functionality works
|
||||
});
|
||||
});
|
||||
|
||||
it("should return external redirect URL when routing to external URL", async () => {
|
||||
// Create a routing form with external redirect action
|
||||
const externalRoutingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
|
||||
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: team.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(
|
||||
`/v2/organizations/${org.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())
|
||||
|
||||
+39
-1
@@ -9,13 +9,28 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.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";
|
||||
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseIntPipe,
|
||||
Req,
|
||||
} 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";
|
||||
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
|
||||
import { CreateRoutingFormResponseOutput } from "../outputs/create-routing-form-response.output";
|
||||
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
|
||||
|
||||
@Controller({
|
||||
@@ -56,6 +71,29 @@ export class OrganizationsRoutingFormsResponsesController {
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({ summary: "Create routing form response and get available slots" })
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
async createRoutingFormResponse(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("routingFormId") routingFormId: string,
|
||||
@Query() query: CreateRoutingFormResponseInput,
|
||||
@Req() request: Request
|
||||
): Promise<CreateRoutingFormResponseOutput> {
|
||||
const result = await this.organizationsRoutingFormsResponsesService.createRoutingFormResponseWithSlots(
|
||||
orgId,
|
||||
routingFormId,
|
||||
query,
|
||||
request
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:responseId")
|
||||
@ApiOperation({ summary: "Update routing form response" })
|
||||
@Roles("ORG_ADMIN")
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
|
||||
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";
|
||||
}
|
||||
return undefined;
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: "Whether to queue the form response.",
|
||||
example: true,
|
||||
})
|
||||
queueResponse?: boolean;
|
||||
}
|
||||
+4
-1
@@ -4,7 +4,9 @@ import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
|
||||
@@ -14,7 +16,7 @@ import { OrganizationsRoutingFormsResponsesService } from "./services/organizati
|
||||
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule, SlotsModule_2024_09_04],
|
||||
providers: [
|
||||
MembershipsRepository,
|
||||
OrganizationsRepository,
|
||||
@@ -22,6 +24,7 @@ import { OrganizationsRoutingFormsService } from "./services/organizations-routi
|
||||
OrganizationsRoutingFormsService,
|
||||
OrganizationsRoutingFormsResponsesService,
|
||||
OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
TeamsEventTypesRepository,
|
||||
],
|
||||
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
|
||||
})
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
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";
|
||||
|
||||
class Routing {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "The ID of the queued form response. Only present if the form response was queued.",
|
||||
example: "123",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
queuedResponseId?: string | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
description: "The ID of the routing form response.",
|
||||
example: 123,
|
||||
})
|
||||
@IsInt()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
responseId?: number | null;
|
||||
|
||||
@ApiProperty({
|
||||
type: [Number],
|
||||
description: "Array of team member IDs that were routed to handle this booking.",
|
||||
example: [101, 102],
|
||||
})
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
teamMemberIds!: number[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The email of the team member assigned to handle this booking.",
|
||||
example: "john.doe@example.com",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
teamMemberEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
description: "Whether to skip contact owner assignment from CRM integration.",
|
||||
example: true,
|
||||
})
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
skipContactOwner?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM application slug for integration.",
|
||||
example: "salesforce",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmAppSlug?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The CRM owner record type for contact assignment.",
|
||||
example: "Account",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
crmOwnerRecordType?: string;
|
||||
}
|
||||
|
||||
export class CreateRoutingFormResponseOutputData {
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
description: "The ID of the event type that was routed to.",
|
||||
example: 123,
|
||||
})
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
eventTypeId?: number;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: Routing })
|
||||
@Type(() => Routing)
|
||||
@ApiPropertyOptional({
|
||||
type: Routing,
|
||||
description: "The routing information.",
|
||||
example: {
|
||||
eventTypeId: 123,
|
||||
routing: {
|
||||
teamMemberIds: [101, 102],
|
||||
teamMemberEmail: "john.doe@example.com",
|
||||
skipContactOwner: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => Routing)
|
||||
routing?: Routing;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "A custom message to be displayed to the user in case of routing to a custom page.",
|
||||
example: "This is a custom message.",
|
||||
})
|
||||
routingCustomMessage?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The external redirect URL to be used in case of routing to a non cal.com event type URL.",
|
||||
example: "https://example.com/",
|
||||
})
|
||||
routingExternalRedirectUrl?: string;
|
||||
|
||||
@ValidateNested()
|
||||
@ApiProperty({
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(SlotsOutput_2024_09_04) },
|
||||
{ $ref: getSchemaPath(RangeSlotsOutput_2024_09_04) },
|
||||
],
|
||||
})
|
||||
@Type(() => Object)
|
||||
slots?: SlotsOutput_2024_09_04 | RangeSlotsOutput_2024_09_04;
|
||||
}
|
||||
|
||||
export class CreateRoutingFormResponseOutput extends ApiResponseWithoutData {
|
||||
@ValidateNested()
|
||||
@ApiProperty({ type: CreateRoutingFormResponseOutputData })
|
||||
@Type(() => CreateRoutingFormResponseOutputData)
|
||||
data!: CreateRoutingFormResponseOutputData;
|
||||
}
|
||||
+215
-2
@@ -1,12 +1,28 @@
|
||||
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
|
||||
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
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 { 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";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsRoutingFormsResponsesService {
|
||||
constructor(
|
||||
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
|
||||
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService,
|
||||
private readonly slotsService: SlotsService_2024_09_04,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
) {}
|
||||
|
||||
async getOrganizationRoutingFormResponses(
|
||||
@@ -52,4 +68,201 @@ 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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -65,7 +65,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL for a team" })
|
||||
@ApiOperation({ summary: "Get Stripe connect URL for a team" })
|
||||
async getTeamStripeConnectUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@@ -104,7 +104,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check team stripe connection" })
|
||||
@ApiOperation({ summary: "Check team Stripe connection" })
|
||||
async checkTeamStripeConnection(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
@@ -116,7 +116,7 @@ export class OrganizationsStripeController {
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/save")
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
|
||||
+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(
|
||||
|
||||
+10
-1
@@ -6,7 +6,9 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
|
||||
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
|
||||
@@ -18,7 +20,14 @@ import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organ
|
||||
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
|
||||
imports: [
|
||||
PrismaModule,
|
||||
StripeModule,
|
||||
RedisModule,
|
||||
RoutingFormsModule,
|
||||
SlotsModule_2024_09_04,
|
||||
TeamsEventTypesModule,
|
||||
],
|
||||
providers: [
|
||||
OrganizationsTeamsRoutingFormsService,
|
||||
OrganizationsTeamsRoutingFormsResponsesService,
|
||||
|
||||
+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
@@ -34,7 +34,7 @@ export class OrganizationsUsersBookingsController {
|
||||
@Get("/")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all bookings of an organization user" })
|
||||
@ApiOperation({ summary: "Get all bookings for an organization user" })
|
||||
async getOrganizationUserBookings(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsUsersRepository {
|
||||
|
||||
+5
-5
@@ -66,7 +66,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@ApiOperation({ summary: "Get all ooo entries of a user" })
|
||||
@ApiOperation({ summary: "Get all out-of-office entries for a user" })
|
||||
async getOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Query() query: GetOutOfOfficeEntryFiltersDTO
|
||||
@@ -84,7 +84,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg)
|
||||
@ApiOperation({ summary: "Create an ooo entry for user" })
|
||||
@ApiOperation({ summary: "Create an out-of-office entry for a user" })
|
||||
async createOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Body() input: CreateOutOfOfficeEntryDto
|
||||
@@ -100,7 +100,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||
@ApiOperation({ summary: "Update ooo entry of a user" })
|
||||
@ApiOperation({ summary: "Update an out-of-office entry for a user" })
|
||||
async updateOrganizationUserOOO(
|
||||
@Param("userId", ParseIntPipe) userId: number,
|
||||
@Param("oooId", ParseIntPipe) oooId: number,
|
||||
@@ -118,7 +118,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(IsUserInOrg, IsUserOOO)
|
||||
@ApiOperation({ summary: "Delete ooo entry of a user" })
|
||||
@ApiOperation({ summary: "Delete an out-of-office entry for a user" })
|
||||
async deleteOrganizationUserOOO(
|
||||
@Param("oooId", ParseIntPipe) oooId: number
|
||||
): Promise<UserOooOutputResponseDto> {
|
||||
@@ -132,7 +132,7 @@ export class OrganizationsUsersOOOController {
|
||||
@Get("/ooo")
|
||||
@Roles("ORG_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiOperation({ summary: "Get all OOO entries of org users" })
|
||||
@ApiOperation({ summary: "Get all out-of-office entries for organization users" })
|
||||
async getOrganizationUsersOOO(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrgUsersOutOfOfficeEntryFiltersDTO
|
||||
|
||||
@@ -27,7 +27,6 @@ export class RouterController {
|
||||
): 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) {
|
||||
throw new NotFoundException("Route not found. Please check the provided form parameter.");
|
||||
}
|
||||
@@ -37,6 +36,13 @@ export class RouterController {
|
||||
}
|
||||
|
||||
if (routedUrlData?.props) {
|
||||
if (routedUrlData.props.errorMessage) {
|
||||
return {
|
||||
status: "error",
|
||||
error: { code: "ROUTING_ERROR", message: routedUrlData.props.errorMessage },
|
||||
redirect: false,
|
||||
};
|
||||
}
|
||||
return { status: "success", data: { message: routedUrlData.props.message ?? "" }, redirect: false };
|
||||
}
|
||||
|
||||
@@ -54,6 +60,7 @@ export class RouterController {
|
||||
) {
|
||||
return this.handleRedirectWithContactOwner(routingUrl, routingSearchParams);
|
||||
}
|
||||
console.log("handleRedirect Regular called", { destination });
|
||||
|
||||
return { status: "success", data: destination, redirect: true };
|
||||
}
|
||||
@@ -62,6 +69,7 @@ export class RouterController {
|
||||
routingUrl: URL,
|
||||
routingSearchParams: URLSearchParams
|
||||
): Promise<ApiResponse<unknown> & { redirect: boolean }> {
|
||||
console.log("handleRedirectWithContactOwner called", { routingUrl, routingSearchParams });
|
||||
const pathNameParams = routingUrl.pathname.split("/");
|
||||
const eventTypeSlug = pathNameParams[pathNameParams.length - 1];
|
||||
const teamId = Number(routingSearchParams.get("cal.teamId"));
|
||||
|
||||
@@ -23,7 +23,6 @@ export class RoutingFormsService {
|
||||
if (!eventTypeId) {
|
||||
throw new NotFoundException("Event type not found.");
|
||||
}
|
||||
|
||||
const slots = await this.slotsService.getAvailableSlots({
|
||||
type: ById_2024_09_04_type,
|
||||
eventTypeId,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -58,7 +58,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Find out when is an event type ready to be booked.",
|
||||
summary: "Get available time slots for an event type",
|
||||
description: `
|
||||
There are 4 ways to get available slots for event type of an individual user:
|
||||
|
||||
@@ -290,7 +290,7 @@ export class SlotsController_2024_09_04 {
|
||||
|
||||
@Patch("/reservations/:uid")
|
||||
@ApiOperation({
|
||||
summary: "Updated reserved a slot",
|
||||
summary: "Update a reserved slot",
|
||||
})
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async updateReservedSlot(
|
||||
|
||||
@@ -10,7 +10,6 @@ import { DateTime } from "luxon";
|
||||
|
||||
import { dynamicEvent } from "@calcom/platform-libraries";
|
||||
import {
|
||||
ById_2024_09_04,
|
||||
ByUsernameAndEventTypeSlug_2024_09_04,
|
||||
ByTeamSlugAndEventTypeSlug_2024_09_04,
|
||||
GetSlotsInput_2024_09_04,
|
||||
@@ -47,6 +46,10 @@ 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,
|
||||
@@ -59,6 +62,10 @@ export class SlotsInputService_2024_09_04 {
|
||||
timeZone,
|
||||
orgSlug,
|
||||
rescheduleUid,
|
||||
routedTeamMemberIds,
|
||||
skipContactOwner,
|
||||
teamMemberEmail,
|
||||
routingFormResponseId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
@@ -17,7 +18,6 @@ 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";
|
||||
|
||||
@@ -38,15 +38,17 @@ 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) {
|
||||
try {
|
||||
const queryTransformed = await this.slotsInputService.transformGetSlotsQuery(query);
|
||||
const availableSlots: TimeSlots = await getAvailableSlots({
|
||||
const availableSlots: TimeSlots = await this.availableSlotsService.getAvailableSlots({
|
||||
input: {
|
||||
...queryTransformed,
|
||||
routingFormResponseId: queryTransformed.routingFormResponseId ?? undefined,
|
||||
},
|
||||
ctx: {},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -49,7 +49,7 @@ export class StripeController {
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL" })
|
||||
@ApiOperation({ summary: "Get Stripe connect URL" })
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@@ -79,7 +79,7 @@ export class StripeController {
|
||||
@Get("/save")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
@ApiOperation({ summary: "Save Stripe credentials" })
|
||||
/**
|
||||
* Handles saving Stripe credentials.
|
||||
* If both orgId and teamId are present in the callback state, the request is proxied to the organization/team-level endpoint;
|
||||
@@ -151,7 +151,7 @@ export class StripeController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Check stripe connection" })
|
||||
@ApiOperation({ summary: "Check Stripe connection" })
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/cr
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export const MembershipUserSelect: Prisma.UserSelect = {
|
||||
username: true,
|
||||
|
||||
+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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -85,7 +85,7 @@ export class WebhooksController {
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all webooks",
|
||||
summary: "Get all webhooks",
|
||||
description: "Gets a paginated list of webhooks for the authenticated user.",
|
||||
})
|
||||
async getWebhooks(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,11 +14,13 @@ export class ApiKeysRepositoryFixture {
|
||||
|
||||
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
|
||||
const keyString = randomBytes(16).toString("hex");
|
||||
const hashedKey = createHash("sha256").update(keyString).digest("hex");
|
||||
console.log("createApiKey Fixutre", { hashedKey, keyString });
|
||||
const apiKey = await this.prismaWriteClient.apiKey.create({
|
||||
data: {
|
||||
userId,
|
||||
teamId,
|
||||
hashedKey: createHash("sha256").update(keyString).digest("hex"),
|
||||
hashedKey,
|
||||
expiresAt: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
export class AttendeeRepositoryFixture {
|
||||
private prismaReadClient: PrismaReadService["prisma"];
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user