Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d1a873a9 | ||
|
|
54d98e73a2 | ||
|
|
134086f229 | ||
|
|
e9b5aa6f22 | ||
|
|
3908a6cd6f | ||
|
|
886028c400 | ||
|
|
703e20a43a | ||
|
|
b6aedb830b | ||
|
|
46c7afade6 | ||
|
|
5c7d7579ee | ||
|
|
d158a1e007 | ||
|
|
1fb4147940 | ||
|
|
9cf76cbb2f | ||
|
|
6b00167657 | ||
|
|
592f44eb32 | ||
|
|
00afafb550 | ||
|
|
089941553e | ||
|
|
2dc83a1c55 | ||
|
|
5bfb6fc148 | ||
|
|
31ccdba203 | ||
|
|
82fb5272c8 | ||
|
|
e86ad03cde | ||
|
|
1953402f81 | ||
|
|
d6a086c9c7 | ||
|
|
9a517a89b6 | ||
|
|
19f2197eef | ||
|
|
3d583678cf | ||
|
|
ddde762f9b | ||
|
|
9332eba5d7 | ||
|
|
46d1823ccc | ||
|
|
277da187d0 | ||
|
|
7c6b055cd1 | ||
|
|
97308ebf1c | ||
|
|
c2cf55f10c | ||
|
|
3867ff6874 | ||
|
|
0e1c4e53d8 | ||
|
|
3834e69100 | ||
|
|
a05935cdf6 | ||
|
|
19a0f968ca | ||
|
|
3b945ba439 | ||
|
|
7a47eacb34 | ||
|
|
da853958f7 | ||
|
|
d63d15b7b2 | ||
|
|
6bc040b7bb | ||
|
|
0465c639e6 | ||
|
|
8d59c2fc16 | ||
|
|
cb365117b3 | ||
|
|
7c9aae7eaf | ||
|
|
1e7453edcd | ||
|
|
f353fdf252 | ||
|
|
feff588603 | ||
|
|
8ae5d32fe5 | ||
|
|
9600ab6085 | ||
|
|
417a612aa3 | ||
|
|
4db3c592e5 | ||
|
|
d99db25dca | ||
|
|
ba423e1061 | ||
|
|
d5ca11b27b | ||
|
|
25534f721a | ||
|
|
fad5497363 | ||
|
|
563b31b7d0 | ||
|
|
8c9eb18463 | ||
|
|
0965fad72a | ||
|
|
e76bb37f78 | ||
|
|
b0a85a5637 | ||
|
|
82f374d84c | ||
|
|
9ac037c524 | ||
|
|
f5750f3933 | ||
|
|
8f2cb0522d | ||
|
|
4cd8dbbab4 | ||
|
|
1fb778ae78 | ||
|
|
bc9d76e7da | ||
|
|
af51ce1890 | ||
|
|
75c79f76c8 | ||
|
|
b5b4134b75 | ||
|
|
01ca2ca3de | ||
|
|
f94e80558a | ||
|
|
a5c15e6517 | ||
|
|
aaa39cccf8 | ||
|
|
d24a93962f | ||
|
|
1d9d52ecf8 | ||
|
|
f1a832921a | ||
|
|
af1633db28 | ||
|
|
7353b6a604 | ||
|
|
dab0b4a4d8 | ||
|
|
3711e01a77 | ||
|
|
63c9cb6b92 | ||
|
|
f039e116d7 | ||
|
|
93621baea5 | ||
|
|
eaa17fa48d | ||
|
|
9ce94fbbfc | ||
|
|
69548e6ed0 | ||
|
|
5cd2ba1362 | ||
|
|
f15d347804 | ||
|
|
c95118715a | ||
|
|
21e7ac3cbe | ||
|
|
81de95bd28 | ||
|
|
d80ed07093 | ||
|
|
b2ac86c759 | ||
|
|
295cf40c6e | ||
|
|
92827b2c9d | ||
|
|
e1076690c1 | ||
|
|
70dd9b9c2c | ||
|
|
71bfd17f81 | ||
|
|
5b08b9f11f | ||
|
|
3456477012 | ||
|
|
c654795a3e | ||
|
|
a7b1981cf1 | ||
|
|
48016e0f3e | ||
|
|
30367cd5f3 | ||
|
|
47d822a092 | ||
|
|
b597dad450 | ||
|
|
e67e073f04 | ||
|
|
17f493a488 | ||
|
|
96fcf7beec | ||
|
|
c0aa97178c | ||
|
|
fc8ff2d4ae | ||
|
|
c1a6ccc260 | ||
|
|
b17f47236e | ||
|
|
236fd967ad | ||
|
|
7f5534b340 | ||
|
|
50bacf6ea2 | ||
|
|
0d1adcca58 | ||
|
|
81962efb2e | ||
|
|
da47168941 | ||
|
|
2aa583bb6d | ||
|
|
ea250febbc | ||
|
|
f062014952 | ||
|
|
88961a1c67 | ||
|
|
cd952dc93c | ||
|
|
b0ed1fd5e6 | ||
|
|
be883aac02 | ||
|
|
2ab4e8b40a | ||
|
|
6bd11009a8 | ||
|
|
099c534daa | ||
|
|
7aa1d8035c | ||
|
|
596e9b4e5a | ||
|
|
d04f10e7d1 | ||
|
|
0fec74a7fe | ||
|
|
d927662dd7 | ||
|
|
e1c001baff | ||
|
|
122af4538d |
+16
-3
@@ -117,6 +117,11 @@ NEXT_PUBLIC_PLAIN_CHAT_ID=
|
||||
PLAIN_CHAT_HMAC_SECRET_KEY=
|
||||
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
|
||||
|
||||
|
||||
# Dub Config
|
||||
DUB_API_KEY=
|
||||
NEXT_PUBLIC_DUB_PROGRAM_ID=
|
||||
|
||||
# Zendesk Config
|
||||
NEXT_PUBLIC_ZENDESK_KEY=
|
||||
|
||||
@@ -408,8 +413,8 @@ CAL_VIDEO_ASSUME_ROLE_ARN=
|
||||
# Set this to the link of a working Daily Video meeting link
|
||||
CAL_VIDEO_MEETING_LINK_FOR_TESTING=
|
||||
|
||||
# Used to use Replexica SDK, a tool for real-time AI-powered localization
|
||||
REPLEXICA_API_KEY=
|
||||
# Used to use lingo.dev SDK, a tool for real-time AI-powered localization
|
||||
LINGO_DOT_DEV_API_KEY=
|
||||
|
||||
# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
|
||||
DIRECTORY_IDS_TO_LOG=
|
||||
@@ -419,6 +424,8 @@ DIRECTORY_IDS_TO_LOG=
|
||||
# Read more about it in the README.md
|
||||
NEXT_PUBLIC_SINGLE_ORG_SLUG=
|
||||
|
||||
AKISMET_API_KEY=
|
||||
|
||||
## Env variables related to avoiding booking failures
|
||||
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
|
||||
NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS=
|
||||
@@ -429,4 +436,10 @@ NEXT_PUBLIC_QUERY_AVAILABLE_SLOTS_INTERVAL_SECONDS=
|
||||
# Used to invalidate available slots when navigating to booking form
|
||||
NEXT_PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM=0
|
||||
# Used to enable quick availability checks for x% of all visitors
|
||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
|
||||
|
||||
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
|
||||
NEXT_PUBLIC_HEAD_SCRIPTS=
|
||||
NEXT_PUBLIC_BODY_SCRIPTS=
|
||||
|
||||
DATABASE_CHUNK_SIZE=
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
working-directory: docs
|
||||
run: |
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
npm install -g mintlify
|
||||
npm install -g mintlify@4.0.436
|
||||
mintlify dev &
|
||||
sleep 5 # Let it run for 5 seconds
|
||||
kill $!
|
||||
|
||||
@@ -17,7 +17,7 @@ env:
|
||||
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
|
||||
NODE_OPTIONS: --max-old-space-size=29000
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||
LINGO_DOT_DEV_API_KEY: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
|
||||
@@ -21,5 +21,5 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
with:
|
||||
api-key: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||
api-key: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
|
||||
pull-request: true
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Unit
|
||||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||
LINGO_DOT_DEV_API_KEY: ${{ secrets.CI_LINGO_DOT_DEV_API_KEY }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
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
+7
@@ -0,0 +1,7 @@
|
||||
undecided:
|
||||
- "@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"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@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"
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Org", () => {
|
||||
@@ -8,14 +7,16 @@ test.describe("Org", () => {
|
||||
const response = await page.goto("https://i.cal.com/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.screenshot({ path: "screenshot.jpg" });
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
const body = await response?.text();
|
||||
await expectPageToBeRenderedWithEmbedSsr(body);
|
||||
});
|
||||
|
||||
test("Org User(Rick) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/team-rick/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Used by Checkly")).toBeVisible();
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
const body = await response?.text();
|
||||
await expectPageToBeRenderedWithEmbedSsr(body);
|
||||
});
|
||||
|
||||
test("Org User Event(/team-rick/test-event) Page should be embeddable", async ({ page }) => {
|
||||
@@ -23,14 +24,16 @@ test.describe("Org", () => {
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
const body = await response?.text();
|
||||
await expectPageToBeRenderedWithEmbedSsr(body);
|
||||
});
|
||||
|
||||
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
const body = await response?.text();
|
||||
await expectPageToBeRenderedWithEmbedSsr(body);
|
||||
});
|
||||
|
||||
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
|
||||
@@ -38,7 +41,8 @@ test.describe("Org", () => {
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeRenderedWithEmbedSsr(page);
|
||||
const body = await response?.text();
|
||||
await expectPageToBeRenderedWithEmbedSsr(body);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,7 +72,8 @@ test.describe("Org", () => {
|
||||
test("Organization Homepage - Has Engineering and Marketing Teams", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com")).toBeVisible();
|
||||
// Somehow there are two Cal.com text momentarily, but shouldn't be the concern of this check
|
||||
await expect(page.locator("text=Cal.com").first()).toBeVisible();
|
||||
await expect(page.locator("text=Engineering")).toBeVisible();
|
||||
await expect(page.locator("text=Marketing")).toBeVisible();
|
||||
});
|
||||
@@ -95,11 +100,6 @@ test.describe("Org", () => {
|
||||
});
|
||||
|
||||
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
|
||||
async function expectPageToBeRenderedWithEmbedSsr(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
//@ts-expect-error - __NEXT_DATA__ is a global variable defined by Next.js
|
||||
return window.__NEXT_DATA__.props.pageProps.isEmbed;
|
||||
})
|
||||
).toBe(true);
|
||||
async function expectPageToBeRenderedWithEmbedSsr(responseText: string | undefined) {
|
||||
expect(responseText).toContain('\\"isEmbed\\":true');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
|
||||
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
@@ -103,7 +104,7 @@ const schemaEventTypeEditParams = z
|
||||
.transform((s) => slugify(s))
|
||||
.optional(),
|
||||
length: z.number().int().optional(),
|
||||
seatsPerTimeSlot: z.number().optional(),
|
||||
seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(),
|
||||
seatsShowAttendees: z.boolean().optional(),
|
||||
seatsShowAvailabilityCount: z.boolean().optional(),
|
||||
bookingFields: eventTypeBookingFields.optional(),
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@sentry/nextjs": "^8.8.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"memory-cache": "^0.2.0",
|
||||
"next": "^13.5.6",
|
||||
"next": "^13.5.9",
|
||||
"next-api-middleware": "^1.0.1",
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
|
||||
@@ -79,9 +79,10 @@ async function handler(req: NextApiRequest) {
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
});
|
||||
|
||||
// Normalizing for universal handler
|
||||
req.body = { id, allRemainingBookings, cancellationReason, cancelledBy };
|
||||
return await handleCancelBooking(req);
|
||||
return await handleCancelBooking({
|
||||
bookingData: { id, allRemainingBookings, cancellationReason, cancelledBy },
|
||||
userId: req.userId,
|
||||
});
|
||||
}
|
||||
|
||||
export default defaultResponder(handler);
|
||||
|
||||
@@ -213,12 +213,14 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
|
||||
* description: Authorization information is missing or invalid.
|
||||
*/
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
|
||||
const { isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
|
||||
let userId = req.userId;
|
||||
|
||||
req.body = {
|
||||
...req.body,
|
||||
creationSource: CreationSource.API_V1,
|
||||
};
|
||||
if (isSystemWideAdmin) req.userId = req.body.userId || userId;
|
||||
if (isSystemWideAdmin) userId = req.body.userId || userId;
|
||||
|
||||
if (isOrganizationOwnerOrAdmin) {
|
||||
const accessibleUsersIds = await getAccessibleUsers({
|
||||
@@ -226,11 +228,19 @@ async function handler(req: NextApiRequest) {
|
||||
memberUserIds: [req.body.userId || userId],
|
||||
});
|
||||
const [requestedUserId] = accessibleUsersIds;
|
||||
req.userId = requestedUserId || userId;
|
||||
userId = requestedUserId || userId;
|
||||
}
|
||||
|
||||
try {
|
||||
return await handleNewBooking(req, getBookingDataSchemaForApi);
|
||||
return await handleNewBooking(
|
||||
{
|
||||
bookingData: req.body,
|
||||
userId,
|
||||
hostname: req.headers.host || "",
|
||||
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
|
||||
},
|
||||
getBookingDataSchemaForApi
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
const knownError = error as Error;
|
||||
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -38,7 +39,8 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
await caller.inviteMember({
|
||||
role: data.role,
|
||||
language: data.language,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
import { createCallerFactory } from "@calcom/trpc/server/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -39,7 +40,8 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res }, sessionGetter);
|
||||
try {
|
||||
const caller = viewerTeamsRouter.createCaller(ctx);
|
||||
const createCaller = createCallerFactory(viewerTeamsRouter);
|
||||
const caller = createCaller(ctx);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.publish(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getDubCustomer } from "@calcom/features/auth/lib/dub";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
|
||||
@@ -32,3 +32,9 @@ DOCS_URL=
|
||||
# Axiom logging
|
||||
AXIOM_DATASET=
|
||||
AXIOM_TOKEN=
|
||||
|
||||
# DEBUG: 0
|
||||
# INFO: 1
|
||||
# WARN: 2
|
||||
# ERROR: 3
|
||||
LOGGER_BRIDGE_LOG_LEVEL="1"
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.152",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -17,7 +17,8 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
@@ -36,7 +37,6 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { CreationSource } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { NextApiRequest } from "next/types";
|
||||
@@ -104,7 +104,8 @@ export class BookingsController_2024_04_15 {
|
||||
private readonly config: ConfigService,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -114,18 +115,19 @@ export class BookingsController_2024_04_15 {
|
||||
@ApiQuery({ name: "limit", type: "number", required: false })
|
||||
@ApiQuery({ name: "cursor", type: "number", required: false })
|
||||
async getBookings(
|
||||
@GetUser() user: User,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query() queryParams: GetBookingsInput_2024_04_15
|
||||
): Promise<GetBookingsOutput_2024_04_15> {
|
||||
const { filters, cursor, limit } = queryParams;
|
||||
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
const bookings = await getAllUserBookings({
|
||||
bookingListingByStatus: [bookingListingByStatus],
|
||||
skip: cursor ?? 0,
|
||||
take: limit ?? 10,
|
||||
filters,
|
||||
ctx: {
|
||||
user: { email: user.email, id: user.id },
|
||||
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
},
|
||||
});
|
||||
@@ -174,11 +176,19 @@ export class BookingsController_2024_04_15 {
|
||||
const oAuthClientId =
|
||||
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
|
||||
const { orgSlug, locationUrl } = body;
|
||||
req.headers["x-cal-force-slug"] = orgSlug;
|
||||
try {
|
||||
const booking = await handleNewBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
forcedSlug: orgSlug,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
void (await this.billingService.increaseUsageByUserId(booking.userId, {
|
||||
uid: booking.uid,
|
||||
@@ -214,9 +224,16 @@ export class BookingsController_2024_04_15 {
|
||||
if (bookingUid) {
|
||||
try {
|
||||
req.body.uid = bookingUid;
|
||||
const res = await handleCancelBooking(
|
||||
await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
void (await this.billingService.cancelUsageByBookingUid(res.bookingUid));
|
||||
}
|
||||
@@ -277,9 +294,18 @@ export class BookingsController_2024_04_15 {
|
||||
}
|
||||
}
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
|
||||
await this.createNextApiRecurringBookingRequest(req, oAuthClientId, undefined, isEmbed)
|
||||
);
|
||||
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
|
||||
|
||||
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
createdBookings.forEach(async (booking) => {
|
||||
if (booking.userId && booking.uid && booking.startTime) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outpu
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -33,7 +35,6 @@ import {
|
||||
ApiBody,
|
||||
ApiExtraModels,
|
||||
} from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -79,7 +80,10 @@ import {
|
||||
export class BookingsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
|
||||
constructor(
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly usersService: UsersService
|
||||
) {}
|
||||
|
||||
@Post("/")
|
||||
@ApiOperation({
|
||||
@@ -95,6 +99,10 @@ export class BookingsController_2024_08_13 {
|
||||
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
|
||||
|
||||
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
|
||||
|
||||
Finally, there are 2 ways to book an event type:
|
||||
1. Provide \`eventTypeId\` in the request body.
|
||||
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
|
||||
`,
|
||||
})
|
||||
@ApiBody({
|
||||
@@ -155,19 +163,20 @@ export class BookingsController_2024_08_13 {
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiOperation({ summary: "Get all bookings" })
|
||||
async getBookings(
|
||||
@Query() queryParams: GetBookingsInput_2024_08_13,
|
||||
@GetUser() user: User
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<GetBookingsOutput_2024_08_13> {
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, user);
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
const bookings = await this.bookingsService.getBookings(queryParams, {
|
||||
email: user.email,
|
||||
id: user.id,
|
||||
orgId: profile?.organizationId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -245,13 +254,10 @@ export class BookingsController_2024_08_13 {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Mark a booking absence - provided authorization header refers to owner of the booking.",
|
||||
})
|
||||
@ApiOperation({ summary: "Mark a booking absence" })
|
||||
async markNoShow(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: MarkAbsentBookingInput_2024_08_13,
|
||||
@@ -269,13 +275,11 @@ export class BookingsController_2024_08_13 {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Automatically reassign booking to a new host automatically - provided authorization header refers to person who reassigned the booking.",
|
||||
})
|
||||
@ApiOperation({ summary: "Automatically reassign booking to a new host" })
|
||||
async reassignBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -292,13 +296,11 @@ export class BookingsController_2024_08_13 {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Reassign a booking to a specific user specified by the :userId - provided authorization header refers to person who reassigned the booking.",
|
||||
})
|
||||
@ApiOperation({ summary: "Reassign a booking to a specific user" })
|
||||
async reassignBookingToUser(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Param("userId") userId: number,
|
||||
@@ -322,13 +324,11 @@ export class BookingsController_2024_08_13 {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
})
|
||||
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
|
||||
async confirmBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
@@ -345,13 +345,11 @@ export class BookingsController_2024_08_13 {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Decline booking that requires a confirmation - provided authorization header refers to owner of the booking.",
|
||||
})
|
||||
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
|
||||
async declineBooking(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: DeclineBookingInput_2024_08_13,
|
||||
@@ -368,6 +366,7 @@ export class BookingsController_2024_08_13 {
|
||||
@Get("/:bookingUid/calendar-links")
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@Permissions([BOOKING_READ])
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Get 'Add to Calendar' links for a booking",
|
||||
description:
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
|
||||
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
|
||||
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
|
||||
|
||||
const googleCalendarLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "googleCalendar"
|
||||
).link;
|
||||
const microsoftOfficeLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOffice"
|
||||
).link;
|
||||
const microsoftOutlookLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOutlook"
|
||||
).link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
|
||||
|
||||
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
|
||||
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
|
||||
|
||||
+70
-4
@@ -301,8 +301,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUserEmail,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUserEmail],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -418,8 +418,8 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
attendee: {
|
||||
name: thirdManagedUser.user.name!,
|
||||
email: thirdManagedUser.user.email,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
language: secondManagedUser.user.locale,
|
||||
timeZone: thirdManagedUser.user.timeZone,
|
||||
language: thirdManagedUser.user.locale,
|
||||
},
|
||||
guests: [secondManagedUser.user.email],
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
@@ -477,6 +477,72 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch bookings by attendeeEmail", () => {
|
||||
it("should return bookings for the original email when original email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee only", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${thirdManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
thirdManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(thirdManagedUserBookingsCount).toEqual(2);
|
||||
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the original email when original email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUserEmail}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
|
||||
it("should return bookings for the oAuth email when original oAuth email is attendee or guest", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/bookings?attendeeEmail=${secondManagedUser.user.email}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
|
||||
secondManagedUserAttendeeBookingsResponseBody.data;
|
||||
expect(secondManagedUserBookingsCount).toEqual(4);
|
||||
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
|
||||
@@ -591,18 +591,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(0);
|
||||
});
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should get bookings by teamIds", async () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
@@ -66,6 +66,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
let eventType: EventType;
|
||||
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
let recurringEventTypeId: number;
|
||||
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
|
||||
@@ -128,6 +129,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
eventType = event;
|
||||
|
||||
const recurringEvent = await eventTypesRepositoryFixture.create(
|
||||
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
|
||||
@@ -586,6 +588,65 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get a booking with icsUid", async () => {
|
||||
const mockBooking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)),
|
||||
title: "peer coding lets goo",
|
||||
uid: `booking-in-the-past-${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",
|
||||
},
|
||||
},
|
||||
rating: 10,
|
||||
iCalUID: "ics-uid",
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/bookings/${mockBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toEqual(mockBooking.id);
|
||||
expect(data.uid).toEqual(mockBooking.uid);
|
||||
expect(data.icsUid).toEqual(mockBooking.iCalUID);
|
||||
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibily recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should get 1 recurrence of a recurring booking", async () => {
|
||||
const recurrenceUid = createdRecurringBooking[0].uid;
|
||||
return request(app.getHttpServer())
|
||||
@@ -1604,6 +1665,135 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("book by username and event type slug", () => {
|
||||
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
|
||||
expect(
|
||||
response.body.error.message.includes(
|
||||
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should create a booking by username and event type slug", async () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
|
||||
eventTypeSlug: eventType.slug,
|
||||
username: user.username || "",
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: googleMeetUrl,
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
guests: ["bob@gmail.com"],
|
||||
};
|
||||
|
||||
const beforeCreate = new Date();
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const afterCreate = new Date();
|
||||
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseDataIsBooking(responseBody.data)).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts[0].id).toEqual(user.id);
|
||||
expect(data.hosts[0].username).toEqual(user.username);
|
||||
expect(data.hosts[0].email).toEqual(user.email);
|
||||
expect(data.status).toEqual("accepted");
|
||||
expect(data.start).toEqual(body.start);
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
});
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.location).toEqual(body.location);
|
||||
expect(data.meetingUrl).toEqual(body.location);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
...body.bookingFieldsResponses,
|
||||
guests: body.guests,
|
||||
location: {
|
||||
optionValue: googleMeetUrl,
|
||||
value: "link",
|
||||
},
|
||||
});
|
||||
expect(data.guests).toEqual(body.guests);
|
||||
|
||||
// Check createdAt date is between the time of the request and after the request
|
||||
const createdAtDate = new Date(data.createdAt);
|
||||
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
// Check updatedAt date is between the time of the request and after the request
|
||||
expect(data.updatedAt).toBeDefined();
|
||||
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
|
||||
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
|
||||
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
|
||||
|
||||
expect(data.metadata).toEqual(body.metadata);
|
||||
createdBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking location", () => {
|
||||
const address = "123 Main St";
|
||||
const link = "https://cal.com/join/123456";
|
||||
|
||||
@@ -6,6 +6,8 @@ import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { BillingService } from "@/modules/billing/services/billing.service";
|
||||
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
|
||||
@@ -43,7 +45,7 @@ import {
|
||||
CancelBookingInput,
|
||||
} from "@calcom/platform-types";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
import { EventType, User, Team } from "@calcom/prisma/client";
|
||||
|
||||
type CreatedBooking = {
|
||||
hosts: { id: number }[];
|
||||
@@ -59,6 +61,8 @@ const eventTypeBookingFieldSchema = z.object({
|
||||
|
||||
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
|
||||
|
||||
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
|
||||
|
||||
@Injectable()
|
||||
export class BookingsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingsService");
|
||||
@@ -72,32 +76,49 @@ export class BookingsService_2024_08_13 {
|
||||
private readonly billingService: BillingService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
private readonly platformBookingsService: PlatformBookingsService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async createBooking(request: Request, body: CreateBookingInput) {
|
||||
try {
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body);
|
||||
const eventType = await this.getBookedEventType(body);
|
||||
if (!eventType) {
|
||||
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
|
||||
);
|
||||
}
|
||||
if (body.username && body.eventTypeSlug && body.organizationSlug) {
|
||||
throw new NotFoundException(
|
||||
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
|
||||
);
|
||||
}
|
||||
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
|
||||
}
|
||||
|
||||
body.eventTypeId = eventType.id;
|
||||
|
||||
if ("instant" in body && body.instant) {
|
||||
return await this.createInstantBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
|
||||
const isRecurring = !!eventType?.recurringEvent;
|
||||
const isSeated = !!eventType?.seatsPerTimeSlot;
|
||||
|
||||
await this.hasRequiredBookingFieldsResponses(body, eventType);
|
||||
|
||||
if (isRecurring && isSeated) {
|
||||
return await this.createRecurringSeatedBooking(request, body);
|
||||
return await this.createRecurringSeatedBooking(request, body, eventType);
|
||||
}
|
||||
if (isRecurring && !isSeated) {
|
||||
return await this.createRecurringBooking(request, body);
|
||||
return await this.createRecurringBooking(request, body, eventType);
|
||||
}
|
||||
if (isSeated) {
|
||||
return await this.createSeatedBooking(request, body);
|
||||
return await this.createSeatedBooking(request, body, eventType);
|
||||
}
|
||||
|
||||
return await this.createRegularBooking(request, body);
|
||||
return await this.createRegularBooking(request, body, eventType);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "no_available_users_found_error") {
|
||||
@@ -108,6 +129,22 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
}
|
||||
|
||||
async getBookedEventType(body: CreateBookingInput) {
|
||||
if (body.eventTypeId) {
|
||||
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
|
||||
} else if (body.username && body.eventTypeSlug) {
|
||||
const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug);
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with username ${body.username} not found`);
|
||||
}
|
||||
return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam(
|
||||
user.id,
|
||||
body.eventTypeSlug
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
|
||||
const bookingFields = body.bookingFieldsResponses;
|
||||
if (!bookingFields || !eventType || !eventType.bookingFields) {
|
||||
@@ -130,8 +167,12 @@ export class BookingsService_2024_08_13 {
|
||||
return true;
|
||||
}
|
||||
|
||||
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
async createInstantBooking(
|
||||
request: Request,
|
||||
body: CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
@@ -144,24 +185,52 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
noEmail: bookingRequest.noEmail,
|
||||
});
|
||||
const ids = bookings.map((booking) => booking.id || 0);
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
|
||||
const bookings = await handleNewRecurringBooking(bookingRequest);
|
||||
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
|
||||
const bookings = await handleNewRecurringBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
return this.outputService.getOutputCreateRecurringSeatedBookings(
|
||||
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
|
||||
);
|
||||
}
|
||||
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
@@ -175,9 +244,18 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(databaseBooking);
|
||||
}
|
||||
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13, eventType: EventTypeWithOwnerAndTeam) {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
@@ -225,12 +303,23 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputRecurringBookings(ids);
|
||||
}
|
||||
|
||||
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
|
||||
async getBookings(
|
||||
queryParams: GetBookingsInput_2024_08_13,
|
||||
user: { email: string; id: number; orgId?: number },
|
||||
userIds?: number[]
|
||||
) {
|
||||
if (queryParams.attendeeEmail) {
|
||||
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
|
||||
}
|
||||
|
||||
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
|
||||
bookingListingByStatus: queryParams.status || [],
|
||||
skip: queryParams.skip ?? 0,
|
||||
take: queryParams.take ?? 100,
|
||||
filters: this.inputService.transformGetBookingsFilters(queryParams),
|
||||
filters: {
|
||||
...this.inputService.transformGetBookingsFilters(queryParams),
|
||||
...(userIds?.length ? { userIds } : {}),
|
||||
},
|
||||
ctx: {
|
||||
user,
|
||||
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
|
||||
@@ -281,6 +370,35 @@ export class BookingsService_2024_08_13 {
|
||||
return formattedBookings;
|
||||
}
|
||||
|
||||
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
|
||||
// note(Lauris): this is to handle attendees that are managed users - in attendee table their email is one of managed users e.g
|
||||
// urdasdqinm+clxyyy21o0003sbk7yw5z6tzg@example.com but if attendeeEmail is passed as urdasdqinm@example.com then we check if user whose
|
||||
// access token is used is a managed user and if attendee with passed email has managed user email composed of passed email without oAuth client id +
|
||||
// authenticated user oAuth client id.
|
||||
const oAuthClient = await this.oAuthClientRepository.getByUserId(user.id);
|
||||
if (!oAuthClient) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
// note(Lauris): query param already contains oAuth client id in the attendeeEmail
|
||||
if (queryParamsAttendeeEmail.includes(oAuthClient.id)) {
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
const managedAttendeeEmail = OAuthClientUsersService.getOAuthUserEmail(
|
||||
oAuthClient.id,
|
||||
queryParamsAttendeeEmail
|
||||
);
|
||||
const [attendee, managedAttendee] = await Promise.all([
|
||||
this.usersRepository.findByEmail(queryParamsAttendeeEmail),
|
||||
this.usersRepository.findByEmail(managedAttendeeEmail),
|
||||
]);
|
||||
if (!attendee && managedAttendee) {
|
||||
return managedAttendeeEmail;
|
||||
}
|
||||
|
||||
return queryParamsAttendeeEmail;
|
||||
}
|
||||
|
||||
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
|
||||
try {
|
||||
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
|
||||
@@ -288,7 +406,16 @@ export class BookingsService_2024_08_13 {
|
||||
bookingUid,
|
||||
body
|
||||
);
|
||||
const booking = await handleNewBooking(bookingRequest);
|
||||
const booking = await handleNewBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
hostname: bookingRequest.headers?.host || "",
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
platformBookingLocation: bookingRequest.platformBookingLocation,
|
||||
});
|
||||
if (!booking.uid) {
|
||||
throw new Error("Booking missing uid");
|
||||
}
|
||||
@@ -344,7 +471,16 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
|
||||
const res = await handleCancelBooking(bookingRequest);
|
||||
const res = await handleCancelBooking({
|
||||
bookingData: bookingRequest.body,
|
||||
userId: bookingRequest.userId,
|
||||
arePlatformEmailsEnabled: bookingRequest.arePlatformEmailsEnabled,
|
||||
platformClientId: bookingRequest.platformClientId,
|
||||
platformCancelUrl: bookingRequest.platformCancelUrl,
|
||||
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
|
||||
platformBookingUrl: bookingRequest.platformBookingUrl,
|
||||
});
|
||||
|
||||
if (!res.onlyRemovedAttendee) {
|
||||
await this.billingService.cancelUsageByBookingUid(res.bookingUid);
|
||||
}
|
||||
@@ -448,6 +584,7 @@ export class BookingsService_2024_08_13 {
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
reassignedById: requestUser.id,
|
||||
});
|
||||
|
||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
@@ -45,7 +46,10 @@ import {
|
||||
import { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input";
|
||||
import { EventType } from "@calcom/prisma/client";
|
||||
|
||||
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
|
||||
type BookingRequest = NextApiRequest & {
|
||||
userId: number | undefined;
|
||||
noEmail: boolean | undefined;
|
||||
} & OAuthRequestParams;
|
||||
|
||||
type OAuthRequestParams = {
|
||||
platformClientId: string;
|
||||
@@ -92,10 +96,15 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async createBookingRequest(
|
||||
request: Request,
|
||||
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
|
||||
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
): Promise<BookingRequest> {
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
|
||||
const bodyTransformed = await this.transformInputCreateBooking(body, oAuthClientParams?.platformClientId);
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
|
||||
const bodyTransformed = await this.transformInputCreateBooking(
|
||||
body,
|
||||
eventType,
|
||||
oAuthClientParams?.platformClientId
|
||||
);
|
||||
|
||||
const newRequest = { ...request };
|
||||
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
|
||||
@@ -121,15 +130,11 @@ export class InputBookingsService_2024_08_13 {
|
||||
return newRequest as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13, platformClientId?: string) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
|
||||
async transformInputCreateBooking(
|
||||
inputBooking: CreateBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
platformClientId?: string
|
||||
) {
|
||||
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||
|
||||
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||
@@ -196,12 +201,14 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async createRecurringBookingRequest(
|
||||
request: Request,
|
||||
body: CreateRecurringBookingInput_2024_08_13
|
||||
body: CreateRecurringBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam
|
||||
): Promise<BookingRequest> {
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
|
||||
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
|
||||
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
|
||||
const bodyTransformed = await this.transformInputCreateRecurringBooking(
|
||||
body,
|
||||
eventType,
|
||||
oAuthClientParams?.platformClientId
|
||||
);
|
||||
|
||||
@@ -223,7 +230,13 @@ export class InputBookingsService_2024_08_13 {
|
||||
creationSource: CreationSource.API_V2,
|
||||
}));
|
||||
|
||||
return newRequest as unknown as BookingRequest;
|
||||
return {
|
||||
...newRequest,
|
||||
headers: {
|
||||
hostname: request.headers["host"] || "",
|
||||
forcedSlug: request.headers["x-cal-force-slug"] as string | undefined,
|
||||
},
|
||||
} as unknown as BookingRequest;
|
||||
}
|
||||
|
||||
transformLocation(location: string | BookingInputLocation_2024_08_13): {
|
||||
@@ -366,14 +379,9 @@ export class InputBookingsService_2024_08_13 {
|
||||
|
||||
async transformInputCreateRecurringBooking(
|
||||
inputBooking: CreateRecurringBookingInput_2024_08_13,
|
||||
eventType: EventTypeWithOwnerAndTeam,
|
||||
platformClientId?: string
|
||||
) {
|
||||
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
|
||||
inputBooking.eventTypeId
|
||||
);
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
|
||||
}
|
||||
if (!eventType.recurringEvent) {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -231,6 +232,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
|
||||
@@ -279,6 +281,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
@@ -388,6 +391,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
createdAt: databaseBooking.createdAt,
|
||||
updatedAt: databaseBooking.updatedAt,
|
||||
rating: databaseBooking.rating,
|
||||
icsUid: databaseBooking.iCalUID,
|
||||
};
|
||||
|
||||
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
|
||||
|
||||
@@ -59,4 +59,28 @@ export class PlatformBookingsService {
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getOAuthClientParamsForEventType(eventType: {
|
||||
userId: number | null | undefined;
|
||||
teamId: number | null | undefined;
|
||||
}) {
|
||||
let oAuthClient: PlatformOAuthClient | null = null;
|
||||
if (eventType.userId) {
|
||||
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
|
||||
} else if (eventType.teamId) {
|
||||
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
|
||||
}
|
||||
|
||||
if (oAuthClient) {
|
||||
return {
|
||||
platformClientId: oAuthClient.id,
|
||||
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
|
||||
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
|
||||
platformBookingUrl: oAuthClient.bookingRedirectUri,
|
||||
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
|
||||
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
|
||||
import { OutlookService } from "@/ee/calendars/services/outlook.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -34,7 +35,7 @@ import {
|
||||
Post,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
@@ -68,6 +69,7 @@ export class CalendarsController {
|
||||
|
||||
@Post("/ics-feed/save")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Save an ICS feed" })
|
||||
async createIcsFeed(
|
||||
@GetUser("id") userId: number,
|
||||
@@ -79,12 +81,14 @@ export class CalendarsController {
|
||||
|
||||
@Get("/ics-feed/check")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Check an ICS feed" })
|
||||
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
|
||||
return await this.icsFeedService.check(userId);
|
||||
}
|
||||
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/busy-times")
|
||||
@ApiOperation({
|
||||
summary: "Get busy times",
|
||||
@@ -113,6 +117,7 @@ export class CalendarsController {
|
||||
|
||||
@Get("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get all calendars" })
|
||||
async getCalendars(@GetUser("id") userId: number): Promise<ConnectedCalendarsOutput> {
|
||||
const calendars = await this.calendarsService.getCalendars(userId);
|
||||
@@ -129,9 +134,16 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Get("/:calendar/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get oAuth connect URL" })
|
||||
@ApiQuery({
|
||||
name: "redir",
|
||||
required: false,
|
||||
type: String,
|
||||
description: "Redirect URL after successful calendar authorization.",
|
||||
})
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@@ -193,6 +205,7 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/:calendar/credentials")
|
||||
@ApiOperation({ summary: "Sync credentials" })
|
||||
async syncCredentials(
|
||||
@@ -221,6 +234,7 @@ export class CalendarsController {
|
||||
@Get("/:calendar/check")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection" })
|
||||
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
|
||||
@@ -245,6 +259,7 @@ export class CalendarsController {
|
||||
name: "calendar",
|
||||
})
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@Post("/:calendar/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Disconnect a calendar" })
|
||||
|
||||
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
await this.credentialRepository.upsertUserAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
@@ -126,7 +126,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class CalendarsService {
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
const credential = await this.credentialsRepository.upsertUserAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
|
||||
@@ -84,7 +84,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -52,7 +52,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialRepository.upsertAppCredential(
|
||||
const credential = await this.credentialRepository.upsertUserAppCredential(
|
||||
ICS_CALENDAR_TYPE,
|
||||
data.key,
|
||||
userId
|
||||
@@ -75,7 +75,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
|
||||
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
ICS_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!icsFeedCredentials) {
|
||||
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
+9
-3
@@ -10,6 +10,7 @@ import {
|
||||
IsOptional,
|
||||
ValidateNested,
|
||||
Min,
|
||||
Max,
|
||||
IsInt,
|
||||
IsEnum,
|
||||
IsArray,
|
||||
@@ -17,6 +18,8 @@ import {
|
||||
IsNumber,
|
||||
} from "class-validator";
|
||||
|
||||
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/platform-constants";
|
||||
|
||||
// note(Lauris): We will gradually expose more properties if any customer needs them.
|
||||
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
|
||||
|
||||
@@ -414,9 +417,12 @@ export class UpdateEventTypeInput_2024_04_15 {
|
||||
@ApiPropertyOptional()
|
||||
afterEventBuffer?: number;
|
||||
|
||||
// @IsInt()
|
||||
// @IsOptional()
|
||||
// seatsPerTimeSlot?: number;
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(MAX_SEATS_PER_TIME_SLOT)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional()
|
||||
seatsPerTimeSlot?: number;
|
||||
|
||||
// @IsBoolean()
|
||||
// @IsOptional()
|
||||
|
||||
+38
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on user", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
@@ -1090,6 +1111,23 @@ describe("Event types Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on user", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
|
||||
+7
-26
@@ -7,6 +7,7 @@ import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_202
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -67,19 +68,14 @@ export class EventTypesController_2024_06_14 {
|
||||
@Post("/")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Create an event type" })
|
||||
async createEventType(
|
||||
@Body() body: CreateEventTypeInput_2024_06_14,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
|
||||
user.id,
|
||||
user,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -94,12 +90,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@Get("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_READ])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get an event type" })
|
||||
async getEventTypeById(
|
||||
@Param("eventTypeId") eventTypeId: string,
|
||||
@@ -149,12 +140,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@Patch("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Update an event type" })
|
||||
async updateEventType(
|
||||
@@ -164,7 +150,7 @@ export class EventTypesController_2024_06_14 {
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
|
||||
body,
|
||||
user.id,
|
||||
user,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
@@ -179,12 +165,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@Delete("/:eventTypeId")
|
||||
@Permissions([EVENT_TYPE_WRITE])
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Delete an event type" })
|
||||
async deleteEventType(
|
||||
@Param("eventTypeId") eventTypeId: number,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -110,6 +111,18 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeBySlugWithOwnerAndTeam(userId: number, slug: string) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: {
|
||||
userId_slug: {
|
||||
userId: userId,
|
||||
slug: slug,
|
||||
},
|
||||
},
|
||||
include: { owner: true, team: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEventType(eventTypeId: number) {
|
||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
|
||||
+28
-7
@@ -1,6 +1,7 @@
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -49,13 +50,15 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
inputEventType: CreateEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
const transformedBody = this.transformInputCreateEventType(inputEventType);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
async transformAndValidateUpdateEventTypeInput(
|
||||
inputEventType: UpdateEventTypeInput_2024_06_14,
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
eventTypeId: number
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
|
||||
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
user: UserWithProfile,
|
||||
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,7 +94,10 @@ export class GcalController {
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection status" })
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
|
||||
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -10,7 +11,7 @@ import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-us
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Controller, UseGuards, Get, Patch, Body } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { userSchemaResponse } from "@calcom/platform-types";
|
||||
@@ -21,6 +22,7 @@ import { userSchemaResponse } from "@calcom/platform-types";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, PermissionsGuard)
|
||||
@DocsTags("Me")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class MeController {
|
||||
constructor(
|
||||
private readonly usersRepository: UsersRepository,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
|
||||
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
UnauthorizedException,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiExcludeController, ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -24,6 +25,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
path: "/v2/provider",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
// note(Lauris): this controller is used by useOAuthClient and useOAuthFlow internal hooks and if customer
|
||||
// wants to know if access token is expired then the flow is to make request and then receive message it expired.
|
||||
@ApiExcludeController()
|
||||
@DocsTags("Platform / Cal Provider")
|
||||
export class CalProviderController {
|
||||
constructor(private readonly oauthClientRepository: OAuthClientRepository) {}
|
||||
@@ -53,6 +57,7 @@ export class CalProviderController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Verify an access token" })
|
||||
@ApiHeader(ACCESS_TOKEN_HEADER)
|
||||
async verifyAccessToken(
|
||||
@Param("clientId") clientId: string,
|
||||
@GetUser() user: UserWithProfile
|
||||
|
||||
+2
-6
@@ -1,5 +1,6 @@
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { VERSION_2024_06_14, VERSION_2024_06_11 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -46,12 +47,7 @@ import {
|
||||
default: VERSION_2024_06_11,
|
||||
},
|
||||
})
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
|
||||
required: true,
|
||||
})
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class SchedulesController_2024_06_11 {
|
||||
constructor(private readonly schedulesService: SchedulesService_2024_06_11) {}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -14,8 +14,8 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const statusCode = exception.getStatus();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
body: request.body,
|
||||
|
||||
@@ -40,7 +40,8 @@ export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`PrismaError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -87,7 +87,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
break;
|
||||
}
|
||||
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
|
||||
exception,
|
||||
|
||||
@@ -15,7 +15,8 @@ export class ZodExceptionFilter implements ExceptionFilter {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const requestId = request.headers["X-Request-Id"];
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
|
||||
this.logger.error(`ZodError: ${error.message}`, {
|
||||
error,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ApiHeaderOptions } from "@nestjs/swagger";
|
||||
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
export const OPTIONAL_X_CAL_CLIENT_ID_HEADER: ApiHeaderOptions = {
|
||||
name: X_CAL_CLIENT_ID,
|
||||
description: "For platform customers - OAuth client ID",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const OPTIONAL_X_CAL_SECRET_KEY_HEADER: ApiHeaderOptions = {
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "For platform customers - OAuth client secret key",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const X_CAL_CLIENT_ID_HEADER: ApiHeaderOptions = {
|
||||
name: X_CAL_CLIENT_ID,
|
||||
description: "For platform customers - OAuth client ID",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const X_CAL_SECRET_KEY_HEADER: ApiHeaderOptions = {
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "For platform customers - OAuth client secret key",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const OPTIONAL_API_KEY_HEADER: ApiHeaderOptions = {
|
||||
name: "Authorization",
|
||||
description:
|
||||
"For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const API_KEY_HEADER: ApiHeaderOptions = {
|
||||
name: "Authorization",
|
||||
description: "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
|
||||
required: true,
|
||||
};
|
||||
|
||||
export const API_KEY_OR_ACCESS_TOKEN_HEADER: ApiHeaderOptions = {
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
required: true,
|
||||
};
|
||||
|
||||
export const OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER: ApiHeaderOptions = {
|
||||
name: "Authorization",
|
||||
description:
|
||||
"value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
required: false,
|
||||
};
|
||||
|
||||
export const ACCESS_TOKEN_HEADER: ApiHeaderOptions = {
|
||||
name: "Authorization",
|
||||
description: "value must be `Bearer <token>` where `<token>` is managed user access token",
|
||||
required: true,
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Injectable, Logger as NestLogger, Scope } from "@nestjs/common";
|
||||
|
||||
// 1. Define an interface for the settings
|
||||
interface IMyLoggerSettings {
|
||||
minLevel: number; // Example: 0=debug, 1=info, 2=warn, 3=error
|
||||
displayTimestamp: boolean;
|
||||
logFormat: "pretty" | "json" | "simple";
|
||||
// Add any other settings you need, mimicking tslog or your own requirements
|
||||
// e.g., name?: string; displayFunctionName?: boolean; etc.
|
||||
}
|
||||
|
||||
// Define log level constants (optional but recommended for readability)
|
||||
const LogLevel = {
|
||||
DEBUG: 0,
|
||||
INFO: 1,
|
||||
WARN: 2,
|
||||
ERROR: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* This logger acts as a bridge between Nest.js Logger and log calls originating
|
||||
* from platform libraries. It forwards logs to NestLogger, allowing centralization
|
||||
* (e.g., sending to Axiom) and adds an optional prefix for context.
|
||||
*/
|
||||
@Injectable({ scope: Scope.TRANSIENT }) // TRANSIENT ensures getSubLogger provides truly independent instances if needed elsewhere
|
||||
export class Logger {
|
||||
// Use NestLogger for the actual logging output
|
||||
private readonly nestLogger = new NestLogger("LoggerBridge");
|
||||
// Prefix to add to messages for this instance
|
||||
private prefix = "";
|
||||
|
||||
// Add a public `settings` property, typed with the interface
|
||||
public settings: IMyLoggerSettings;
|
||||
|
||||
// Define default settings
|
||||
private static readonly defaultSettings: IMyLoggerSettings = {
|
||||
minLevel: process?.env?.LOGGER_BRIDGE_LOG_LEVEL
|
||||
? Number(process.env.LOGGER_BRIDGE_LOG_LEVEL)
|
||||
: LogLevel.INFO, // Default to INFO level
|
||||
displayTimestamp: true,
|
||||
logFormat: "pretty",
|
||||
};
|
||||
|
||||
constructor(userSettings?: Partial<IMyLoggerSettings>) {
|
||||
// Merge default settings with user-provided settings
|
||||
// User settings override defaults
|
||||
this.settings = {
|
||||
...Logger.defaultSettings,
|
||||
...userSettings,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new LoggerBridge instance with a specific prefix.
|
||||
* Useful for providing context from different parts of a library.
|
||||
* @param options - Configuration for the sub-logger.
|
||||
* @param options.prefix - An array of strings to join as the log prefix.
|
||||
* @returns A new LoggerBridge instance with the specified prefix.
|
||||
*/
|
||||
getSubLogger(options: { prefix?: string[] }): Logger {
|
||||
const newLogger = new Logger();
|
||||
// Set the prefix for the *new* instance
|
||||
newLogger.prefix = options?.prefix?.join(" ") ?? "";
|
||||
return newLogger;
|
||||
}
|
||||
|
||||
// --- Public logging methods ---
|
||||
|
||||
info(...args: any[]) {
|
||||
this.settings.minLevel <= 1 && this.logInternal("log", ...args);
|
||||
}
|
||||
|
||||
warn(...args: any[]) {
|
||||
this.settings.minLevel <= 2 && this.logInternal("warn", ...args);
|
||||
}
|
||||
|
||||
error(...args: any[]) {
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", ...args);
|
||||
}
|
||||
|
||||
debug(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("debug", ...args);
|
||||
}
|
||||
|
||||
trace(...args: any[]) {
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", ...args);
|
||||
}
|
||||
|
||||
fatal(...args: any[]) {
|
||||
// Prepend FATAL: to the message and log as error
|
||||
const fatalMessage = `fatal: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel <= 3 && this.logInternal("error", fatalMessage);
|
||||
}
|
||||
|
||||
silly(...args: any[]) {
|
||||
const sillyMessage = `silly: ${this.formatArgsAsString(args)}`;
|
||||
this.settings.minLevel === 0 && this.logInternal("verbose", sillyMessage);
|
||||
}
|
||||
|
||||
// --- Internal logging implementation ---
|
||||
|
||||
/** Formats arguments into a single string */
|
||||
private formatArgsAsString(args: any[]): string {
|
||||
return args
|
||||
.map((arg) => {
|
||||
if (typeof arg === "string") {
|
||||
return arg;
|
||||
}
|
||||
// Attempt to stringify non-string arguments
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch (e) {
|
||||
return "[Unserializable Object]";
|
||||
}
|
||||
})
|
||||
.join(" "); // Use space as separator, adjust if needed
|
||||
}
|
||||
|
||||
/** Central method to forward logs to NestLogger */
|
||||
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: any[]) {
|
||||
try {
|
||||
// Format message from potentially multiple arguments
|
||||
const formattedMessage = this.formatArgsAsString(args);
|
||||
|
||||
// Prepend the prefix if it exists
|
||||
const message = this.prefix ? `${this.prefix} ${formattedMessage}` : formattedMessage;
|
||||
|
||||
// Call the corresponding NestLogger method
|
||||
switch (level) {
|
||||
case "log":
|
||||
this.nestLogger.log(message);
|
||||
break;
|
||||
case "warn":
|
||||
this.nestLogger.warn(message);
|
||||
break;
|
||||
case "error":
|
||||
this.nestLogger.error(message);
|
||||
break;
|
||||
case "debug":
|
||||
this.nestLogger.debug(message);
|
||||
break;
|
||||
case "verbose":
|
||||
this.nestLogger.verbose(message);
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
this.nestLogger.error(
|
||||
`Could not bridge log message. Error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { RefreshApiKeyInput } from "@/modules/api-keys/inputs/refresh-api-key.input";
|
||||
import { RefreshApiKeyOutput } from "@/modules/api-keys/outputs/refresh-api-key.output";
|
||||
import { ApiKeysService } from "@/modules/api-keys/services/api-keys.service";
|
||||
@@ -23,12 +24,7 @@ export class ApiKeysController {
|
||||
summary: "Refresh API Key",
|
||||
description: `Generate a new API key and delete the current one. Provide API key to refresh as a Bearer token in the Authorization header (e.g. "Authorization: Bearer <apiKey>").`,
|
||||
})
|
||||
@ApiHeader({
|
||||
name: "Authorization",
|
||||
description: "Bearer <apiKey>",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
})
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async refresh(
|
||||
|
||||
@@ -31,6 +31,15 @@ export class AppsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
|
||||
return this.dbWrite.prisma.credential.deleteMany({
|
||||
where: {
|
||||
id: { in: credentialIdsToDelete },
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
|
||||
@@ -2,12 +2,14 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -18,12 +20,14 @@ import { Module } from "@nestjs/common";
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
AttributesAtomsService,
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
|
||||
@@ -3,10 +3,19 @@ import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -27,6 +36,8 @@ import { ConnectedApps } from "@calcom/platform-libraries/app-store";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
@@ -43,7 +54,8 @@ these endpoints should not be recommended for use by third party and are exclude
|
||||
export class AtomsController {
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesAtomService,
|
||||
private readonly conferencingService: ConferencingAtomsService
|
||||
private readonly conferencingService: ConferencingAtomsService,
|
||||
private readonly attributesService: AttributesAtomsService
|
||||
) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@@ -60,14 +72,27 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypes(userId);
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +141,22 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -135,9 +176,11 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@@ -155,13 +198,54 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user);
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async findTeamMembersMatchingAttributes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: FindTeamMembersMatchingAttributeQueryDto
|
||||
): Promise<FindTeamMembersMatchingAttributeResponseDto> {
|
||||
const result = await this.attributesService.findTeamMembersMatchingAttribute(teamId, orgId, {
|
||||
attributesQueryValue: query.attributesQueryValue,
|
||||
isPreview: query.isPreview,
|
||||
enablePerf: query.enablePerf,
|
||||
concurrency: query.concurrency,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: conferencingApps };
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
|
||||
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
nullable: true,
|
||||
})
|
||||
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
isPreview?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Boolean,
|
||||
})
|
||||
enablePerf?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Number,
|
||||
})
|
||||
concurrency?: number;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { FindTeamMembersMatchingAttributeOutputDto } from "@calcom/platform-types";
|
||||
|
||||
export class FindTeamMembersMatchingAttributeResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FindTeamMembersMatchingAttributeOutputDto)
|
||||
@Expose()
|
||||
@ApiProperty({ type: FindTeamMembersMatchingAttributeOutputDto })
|
||||
readonly data!: FindTeamMembersMatchingAttributeOutputDto;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { findTeamMembersMatchingAttributeLogic } from "@calcom/platform-libraries";
|
||||
|
||||
import { FindTeamMembersMatchingAttributeQueryDto } from "../inputs/find-team-members-matching-attribute.input";
|
||||
|
||||
@Injectable()
|
||||
export class AttributesAtomsService {
|
||||
private logger = new Logger("AttributesAtomService");
|
||||
|
||||
constructor(private readonly usersRepository: UsersRepository) {}
|
||||
|
||||
async findTeamMembersMatchingAttribute(
|
||||
teamId: number,
|
||||
orgId: number,
|
||||
input: FindTeamMembersMatchingAttributeQueryDto
|
||||
) {
|
||||
const {
|
||||
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
|
||||
mainAttributeLogicBuildingWarnings: mainWarnings,
|
||||
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
|
||||
troubleshooter,
|
||||
} = await findTeamMembersMatchingAttributeLogic(
|
||||
{
|
||||
teamId,
|
||||
orgId,
|
||||
attributesQueryValue: input.attributesQueryValue,
|
||||
},
|
||||
{
|
||||
enablePerf: input.enablePerf,
|
||||
concurrency: input.concurrency,
|
||||
enableTroubleshooter: input.enablePerf,
|
||||
}
|
||||
);
|
||||
|
||||
if (!matchingTeamMembersWithResult) {
|
||||
return {
|
||||
troubleshooter,
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
result: null,
|
||||
};
|
||||
}
|
||||
|
||||
const matchingTeamMembersIds = matchingTeamMembersWithResult.map(
|
||||
(member: { userId: number }) => member.userId
|
||||
);
|
||||
|
||||
const matchingTeamMembers = await this.usersRepository.findByIds(matchingTeamMembersIds);
|
||||
|
||||
return {
|
||||
mainWarnings,
|
||||
fallbackWarnings,
|
||||
troubleshooter: troubleshooter,
|
||||
result: matchingTeamMembers.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
|
||||
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
teamId,
|
||||
includeTeamInstalledApps: true,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
|
||||
|
||||
import { MembershipRole, getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import { checkAdminOrOwner, getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import type { TeamQuery } from "@calcom/platform-libraries";
|
||||
import { enrichUserWithDelegationConferencingCredentialsWithoutOrgId } from "@calcom/platform-libraries/app-store";
|
||||
import { getEnabledAppsFromCredentials, getAppFromSlug } from "@calcom/platform-libraries/app-store";
|
||||
@@ -22,9 +22,11 @@ import type {
|
||||
LocationOption,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
getBulkEventTypes,
|
||||
getEventTypeById,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
bulkUpdateTeamEventsToDefaultLocation,
|
||||
getBulkUserEventTypes,
|
||||
getBulkTeamEventTypes,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
updateEventType,
|
||||
@@ -83,7 +85,11 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return getBulkEventTypes(userId);
|
||||
return getBulkUserEventTypes(userId);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
return getBulkTeamEventTypes(teamId);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
@@ -259,9 +265,7 @@ export class EventTypesAtomService {
|
||||
name: team.name,
|
||||
logoUrl: team.logoUrl,
|
||||
credentialId: c.id,
|
||||
isAdmin:
|
||||
team.members[0].role === MembershipRole.ADMIN ||
|
||||
team.members[0].role === MembershipRole.OWNER,
|
||||
isAdmin: checkAdminOrOwner(team.members[0].role),
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -347,4 +351,12 @@ export class EventTypesAtomService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
|
||||
return bulkUpdateTeamEventsToDefaultLocation({
|
||||
eventTypeIds,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -23,8 +29,28 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.g
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ForbiddenException } from "@nestjs/common";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
|
||||
@@ -47,11 +48,11 @@ describe("PlatformPlanGuard", () => {
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the organization does not exist", async () => {
|
||||
it("should throw ForbiddenException if the organization does not exist", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue(null);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should return true if the organization is not platform", async () => {
|
||||
@@ -66,7 +67,7 @@ describe("PlatformPlanGuard", () => {
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the organization has no subscription", async () => {
|
||||
it("should throw ForbiddenException if the organization has no subscription", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: true,
|
||||
@@ -78,10 +79,10 @@ describe("PlatformPlanGuard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should return false if the user has a lower plan than required", async () => {
|
||||
it("should throw ForbiddenException if the user has a lower plan than required", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(organizationsRepository, "findByIdIncludeBilling").mockResolvedValue({
|
||||
isPlatform: true,
|
||||
@@ -93,7 +94,7 @@ describe("PlatformPlanGuard", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should return true if the result is cached in Redis", async () => {
|
||||
@@ -103,11 +104,11 @@ describe("PlatformPlanGuard", () => {
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if the result is cached in Redis", async () => {
|
||||
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
|
||||
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
|
||||
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { Request } from "express";
|
||||
|
||||
@@ -21,7 +21,21 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan: PlatformPlanType;
|
||||
}): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:platformbilling:${minimumPlan}`;
|
||||
@@ -29,7 +43,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -55,7 +69,12 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
}
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
if (canAccess) {
|
||||
return { canAccess };
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||
if (organization) {
|
||||
request.organization = organization;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkAdminAPIEnabled(
|
||||
organizationId: string
|
||||
): Promise<{ canAccess: boolean; organization?: Team | null }> {
|
||||
let canAccess = false;
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
canAccess: cachedCanAccess,
|
||||
organization: cachedOrg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +69,6 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
return canAccess;
|
||||
return { canAccess, organization: org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
@@ -26,33 +25,45 @@ export class IsOrgGuard implements CanActivate {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||
|
||||
if (canAccess && org) {
|
||||
request.organization = org;
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||
let canAccess = false;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
org: cachedOrg,
|
||||
canAccess: cachedCanAccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization) {
|
||||
request.organization = org;
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
return { canAccess, org };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,13 +24,22 @@ export class RolesGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||
return canAccess;
|
||||
}
|
||||
async checkUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId: string,
|
||||
allowedRole: string
|
||||
): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:roles:${allowedRole}`;
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -130,7 +139,7 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate {
|
||||
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { team?: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
@@ -27,17 +27,25 @@ export class IsTeamInOrg implements CanActivate {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||
|
||||
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
|
||||
if (canAccess && team) {
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||
return { canAccess: true, team };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
@@ -25,7 +26,13 @@ import { ConfigModule } from "@nestjs/config";
|
||||
Office365VideoService,
|
||||
AppsRepository,
|
||||
],
|
||||
exports: [],
|
||||
exports: [
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
],
|
||||
controllers: [ConferencingController],
|
||||
})
|
||||
export class ConferencingModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
@@ -14,18 +15,16 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
@@ -37,7 +36,7 @@ import {
|
||||
Req,
|
||||
HttpException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiParam, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
|
||||
@@ -45,7 +44,8 @@ import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: number;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
@@ -62,14 +62,13 @@ export class ConferencingController {
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Connect your conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
@@ -78,26 +77,17 @@ export class ConferencingController {
|
||||
required: true,
|
||||
})
|
||||
async connect(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetApp(userId);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[GOOGLE_MEET].join(", ")
|
||||
);
|
||||
}
|
||||
const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id);
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
@@ -112,7 +102,6 @@ export class ConferencingController {
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
let credential;
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
@@ -123,27 +112,12 @@ export class ConferencingController {
|
||||
accessToken,
|
||||
};
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/callback")
|
||||
@@ -173,23 +147,18 @@ export class ConferencingController {
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
@@ -204,22 +173,22 @@ export class ConferencingController {
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "List your conferencing applications" })
|
||||
async listInstalledConferencingApps(
|
||||
@GetUser("id") userId: number
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
|
||||
|
||||
const data = conferencingApps.map((conferencingApps) =>
|
||||
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data };
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user.id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:app/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Set your default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
@@ -238,15 +207,17 @@ export class ConferencingController {
|
||||
@Get("/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get your default conferencing application" })
|
||||
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
|
||||
async getDefault(@GetUser() user: UserWithProfile): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id);
|
||||
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
|
||||
}
|
||||
|
||||
@Delete("/:app/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Disconnect your conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
|
||||
@@ -17,6 +17,15 @@ export class ConferencingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApps(teamId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
@@ -28,4 +37,10 @@ export class ConferencingRepository {
|
||||
where: { userId, appId: app },
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import {
|
||||
CONFERENCING_APPS,
|
||||
CAL_VIDEO,
|
||||
GOOGLE_MEET,
|
||||
ZOOM,
|
||||
OFFICE_365_VIDEO,
|
||||
} from "@calcom/platform-constants";
|
||||
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -14,13 +24,53 @@ export class ConferencingService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
) {}
|
||||
|
||||
async getConferencingApps(userId: number) {
|
||||
return this.conferencingRepository.findConferencingApps(userId);
|
||||
}
|
||||
|
||||
async connectUserNonOauthApp(app: string, userId: number) {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetToUser(userId);
|
||||
return credential;
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
userId,
|
||||
teamId
|
||||
);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDefaultConferencingApp(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
|
||||
@@ -63,4 +113,20 @@ export class ConferencingService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateOAuthUrl(app: string, state: OAuthCallbackState) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMeetService {
|
||||
@@ -12,35 +11,51 @@ export class GoogleMeetService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async connectGoogleMeetApp(userId: number) {
|
||||
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
|
||||
async connectGoogleMeetToUser(userId: number) {
|
||||
await this.validateGoogleCalendarConnection(userId, "user");
|
||||
|
||||
const googleMeetExists = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this user.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertUserAppCredential(GOOGLE_MEET_TYPE, {}, userId);
|
||||
}
|
||||
|
||||
async connectGoogleMeetToTeam(teamId: number) {
|
||||
await this.validateGoogleCalendarConnection(teamId, "team");
|
||||
|
||||
const googleMeetExists = await this.credentialsRepository.findCredentialByTypeAndTeamId(
|
||||
GOOGLE_MEET_TYPE,
|
||||
teamId
|
||||
);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this team.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertTeamAppCredential(GOOGLE_MEET_TYPE, {}, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Google Calendar is connected and valid for either a user or a team.
|
||||
*/
|
||||
private async validateGoogleCalendarConnection(id: number, entity: "user" | "team") {
|
||||
const googleCalendar =
|
||||
entity === "user"
|
||||
? await this.credentialsRepository.findCredentialByTypeAndUserId(GOOGLE_CALENDAR_TYPE, id)
|
||||
: await this.credentialsRepository.findCredentialByTypeAndTeamId(GOOGLE_CALENDAR_TYPE, id);
|
||||
|
||||
if (!googleCalendar) {
|
||||
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
|
||||
throw new BadRequestException("Google Meet requires a Google Calendar connection.");
|
||||
}
|
||||
|
||||
if (googleCalendar.invalid) {
|
||||
throw new BadRequestException(
|
||||
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
|
||||
"Google Meet requires a valid Google Calendar connection. Please reconnect Google Calendar."
|
||||
);
|
||||
}
|
||||
|
||||
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
|
||||
if (googleMeet) {
|
||||
throw new BadRequestException("Google Meet is already connected.");
|
||||
}
|
||||
|
||||
const googleMeetCredential = await this.credentialsRepository.upsertAppCredential(
|
||||
GOOGLE_MEET_TYPE,
|
||||
{},
|
||||
userId
|
||||
);
|
||||
|
||||
return googleMeetCredential;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -21,7 +22,11 @@ export class Office365VideoService {
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
|
||||
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getOffice365AppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
|
||||
@@ -56,7 +61,7 @@ export class Office365VideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getOffice365AppKeys();
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
@@ -98,23 +103,30 @@ export class Office365VideoService {
|
||||
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
|
||||
delete responseBody.expires_in;
|
||||
|
||||
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
|
||||
type: OFFICE_365_VIDEO_TYPE,
|
||||
userId,
|
||||
appId: OFFICE_365_VIDEO,
|
||||
});
|
||||
const existingCredentialOffice365Video = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(OFFICE_365_VIDEO_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(OFFICE_365_VIDEO_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
OFFICE_365_VIDEO
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -20,7 +21,11 @@ export class ZoomVideoService {
|
||||
private logger = new Logger("ZoomVideoService");
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getZoomAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(ZOOM);
|
||||
@@ -53,7 +58,7 @@ export class ZoomVideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getZoomAppKeys();
|
||||
const redirectUri = encodeURI(this.redirectUri);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
@@ -90,23 +95,31 @@ export class ZoomVideoService {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
|
||||
type: ZOOM_TYPE,
|
||||
userId,
|
||||
appId: ZOOM,
|
||||
});
|
||||
|
||||
const existingCredentialZoomVideo = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
ZOOM
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async upsertAppCredential(
|
||||
async upsertUserAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number,
|
||||
@@ -33,14 +33,45 @@ export class CredentialsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getByTypeAndUserId(type: string, userId: number) {
|
||||
async upsertTeamAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
teamId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
key,
|
||||
teamId,
|
||||
appId: APPS_TYPE_ID_MAPPING[type],
|
||||
},
|
||||
update: {
|
||||
key,
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findCredentialByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getByTypeAndTeamId(type: string, teamId: number) {
|
||||
findAllCredentialsByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
findCredentialByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
findAllCredentialsByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
+4
-2
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { DestinationCalendarsInputBodyDto } from "@/modules/destination-calendars/inputs/destination-calendars.input";
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Body, Controller, Put, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -19,11 +20,12 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Destination Calendars")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
export class DestinationCalendarsController {
|
||||
constructor(private readonly destinationCalendarsService: DestinationCalendarsService) {}
|
||||
|
||||
@Put("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Update destination calendars" })
|
||||
async updateDestinationCalendars(
|
||||
@Body() input: DestinationCalendarsInputBodyDto,
|
||||
|
||||
@@ -4,7 +4,9 @@ import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
|
||||
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
|
||||
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
|
||||
import { RouterModule } from "@/modules/router/router.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
@@ -27,6 +29,8 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
StripeModule,
|
||||
ConferencingModule,
|
||||
OrganizationsTeamsBookingsModule,
|
||||
OrganizationsUsersBookingsModule,
|
||||
OrganizationsBookingsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
|
||||
import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard";
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
Patch,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Webhook } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
@@ -38,6 +39,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard)
|
||||
@DocsTags("Event Types / Webhooks")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class EventTypeWebhooksController {
|
||||
constructor(
|
||||
private readonly webhooksService: WebhooksService,
|
||||
|
||||
@@ -26,6 +26,10 @@ export class JwtService {
|
||||
// divided by 1000 because iat (issued at time) is in seconds (not milliseconds) as informed by JWT specification
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
decode(token: string): Payload {
|
||||
return this.nestJwtService.decode(token) as Payload;
|
||||
}
|
||||
}
|
||||
|
||||
type Payload = Record<string | number | symbol, any>;
|
||||
|
||||
+140
-6
@@ -7,11 +7,13 @@ import { Locales } from "@/lib/enums/locales";
|
||||
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, EventType } from "@prisma/client";
|
||||
@@ -206,6 +208,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -229,11 +235,33 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
|
||||
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
|
||||
const [emailUser, emailDomain] = responseBody.data.user.email.split("@");
|
||||
const [domainName, TLD] = emailDomain.split(".");
|
||||
expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 1);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -250,6 +278,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Bob Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -273,8 +305,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClient.id, responseBody.data.user.email, 2);
|
||||
await userHasDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -291,6 +343,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
bio: "I am a bio",
|
||||
metadata: {
|
||||
key: "value",
|
||||
},
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -314,8 +370,28 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
|
||||
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
|
||||
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedAccessToken.ownerId).toBeDefined();
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClientEventTypesDisabled.id);
|
||||
expect(decodedRefreshToken.ownerId).toBeDefined();
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
await userConnectedToOAuth(oAuthClientEventTypesDisabled.id, responseBody.data.user.email, 1);
|
||||
await userDoesNotHaveDefaultEventTypes(responseBody.data.user.id);
|
||||
@@ -417,6 +493,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by original email`, async () => {
|
||||
@@ -434,6 +514,8 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed users by original emails`, async () => {
|
||||
@@ -456,6 +538,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET: managed user by oAuth email`, async () => {
|
||||
@@ -478,6 +564,8 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
|
||||
expect(userOne?.email).toEqual(postResponseData.user.email);
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`should error /GET if managed user email is invalid`, async () => {
|
||||
@@ -514,6 +602,10 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(userOne?.name).toEqual(postResponseData.user.name);
|
||||
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
|
||||
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
|
||||
expect(userOne?.bio).toEqual(postResponseData.user.bio);
|
||||
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
|
||||
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
|
||||
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
@@ -530,6 +622,9 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail)
|
||||
);
|
||||
expect(responseBody.data.name).toEqual(postResponseData.user.name);
|
||||
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
|
||||
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
|
||||
});
|
||||
|
||||
it(`/PUT/:id`, async () => {
|
||||
@@ -550,12 +645,51 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)
|
||||
);
|
||||
expect(responseBody.data.name).toEqual(postResponseData.user.name);
|
||||
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
|
||||
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
|
||||
const [emailUser, emailDomain] = responseBody.data.email.split("@");
|
||||
const [domainName, TLD] = emailDomain.split(".");
|
||||
expect(responseBody.data.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
|
||||
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
|
||||
});
|
||||
|
||||
it("should force refresh tokens", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}/force-refresh`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: KeysResponseDto = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.accessTokenExpiresAt).toBeDefined();
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(postResponseData.user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it(`/DELETE/:id`, () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
|
||||
|
||||
+22
-32
@@ -8,9 +8,11 @@ import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oau
|
||||
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
|
||||
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
@@ -31,11 +33,11 @@ import {
|
||||
Query,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
import { User, MembershipRole } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { Pagination } from "@calcom/platform-types";
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth-clients/:clientId/users",
|
||||
@@ -43,6 +45,11 @@ import { Pagination } from "@calcom/platform-types";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, OAuthClientGuard, OrganizationRolesGuard)
|
||||
@DocsTags("Platform / Managed Users")
|
||||
@ApiHeader({
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "OAuth client secret key",
|
||||
required: true,
|
||||
})
|
||||
export class OAuthClientUsersController {
|
||||
private readonly logger = new Logger("UserController");
|
||||
|
||||
@@ -50,7 +57,8 @@ export class OAuthClientUsersController {
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly oAuthClientUsersService: OAuthClientUsersService,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService
|
||||
) {}
|
||||
|
||||
@Get("/")
|
||||
@@ -65,7 +73,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: managedUsers.map((user) => this.getResponseUser(user)),
|
||||
data: managedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,10 +97,11 @@ export class OAuthClientUsersController {
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
user: this.getResponseUser(user),
|
||||
user: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: tokens.refreshToken,
|
||||
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -109,7 +118,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +138,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +157,7 @@ export class OAuthClientUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: this.getResponseUser(user),
|
||||
data: this.oAuthClientUsersOutputService.getResponseUser(user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,8 +165,7 @@ export class OAuthClientUsersController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary: "Force refresh tokens",
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials.
|
||||
Each access token is valid for 60 minutes and each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If you have lost managed user access or refresh token, then you can get new ones by using OAuth credentials. ${TOKENS_DOCS}`,
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@@ -168,11 +176,8 @@ export class OAuthClientUsersController {
|
||||
|
||||
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
id,
|
||||
true
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
@@ -180,6 +185,7 @@ export class OAuthClientUsersController {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -192,20 +198,4 @@ export class OAuthClientUsersController {
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private getResponseUser(user: User): ManagedUserOutput {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
timeZone: user.timeZone,
|
||||
weekStart: user.weekStart,
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
locale: user.locale as Locales,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+3
-11
@@ -1,26 +1,18 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNumber, IsString, ValidateNested } from "class-validator";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateManagedUserData {
|
||||
export class CreateManagedUserData extends KeysDto {
|
||||
@ApiProperty({
|
||||
type: ManagedUserOutput,
|
||||
})
|
||||
@ValidateNested()
|
||||
@Type(() => ManagedUserOutput)
|
||||
user!: ManagedUserOutput;
|
||||
|
||||
@IsString()
|
||||
accessToken!: string;
|
||||
|
||||
@IsString()
|
||||
refreshToken!: string;
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class CreateManagedUserOutput {
|
||||
|
||||
+32
-2
@@ -1,40 +1,54 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsEnum, IsOptional, IsUrl } from "class-validator";
|
||||
import { Expose, Transform } from "class-transformer";
|
||||
import { IsEnum, IsObject, IsOptional, IsUrl } from "class-validator";
|
||||
|
||||
export class ManagedUserOutput {
|
||||
@ApiProperty({ example: 1 })
|
||||
@Expose()
|
||||
id!: number;
|
||||
|
||||
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
|
||||
@Expose()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: "alice", nullable: true })
|
||||
@Expose()
|
||||
username!: string | null;
|
||||
|
||||
@ApiProperty({ example: "alice", nullable: true })
|
||||
@Expose()
|
||||
name!: string | null;
|
||||
|
||||
@ApiProperty({ example: "bio", nullable: true })
|
||||
@Expose()
|
||||
bio!: string | null;
|
||||
|
||||
@ApiProperty({ example: "America/New_York" })
|
||||
@Expose()
|
||||
timeZone!: string;
|
||||
|
||||
@ApiProperty({ example: "Sunday" })
|
||||
@Expose()
|
||||
weekStart!: string;
|
||||
|
||||
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
|
||||
@Transform(({ value }) => value.toISOString())
|
||||
@Expose()
|
||||
createdDate!: Date;
|
||||
|
||||
@ApiProperty({ type: Number, example: 12, nullable: true })
|
||||
@Expose()
|
||||
timeFormat!: number | null;
|
||||
|
||||
@ApiProperty({ type: Number, example: null, nullable: true })
|
||||
@Expose()
|
||||
defaultScheduleId!: number | null;
|
||||
|
||||
@IsEnum(Locales)
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales })
|
||||
@Expose()
|
||||
locale?: Locales;
|
||||
|
||||
@IsUrl()
|
||||
@@ -45,5 +59,21 @@ export class ManagedUserOutput {
|
||||
description: `URL of the user's avatar image`,
|
||||
nullable: true,
|
||||
})
|
||||
@Expose()
|
||||
avatarUrl?: string | null;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
@Transform(
|
||||
// note(Lauris): added this transform because without it metadata is removed for some reason
|
||||
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
|
||||
return obj.metadata || undefined;
|
||||
}
|
||||
)
|
||||
metadata?: Record<string, string | boolean | number>;
|
||||
}
|
||||
|
||||
+7
-2
@@ -16,11 +16,11 @@ import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
|
||||
import { OAuthClientWebhooksService } from "@/modules/webhooks/services/oauth-clients-webhooks.service";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Webhook, MembershipRole } from "@prisma/client";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
import { OAuthClientGuard } from "../../guards/oauth-client-guard";
|
||||
@@ -31,6 +31,11 @@ import { OAuthClientGuard } from "../../guards/oauth-client-guard";
|
||||
})
|
||||
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
|
||||
@DocsTags("Platform / Webhooks")
|
||||
@ApiHeader({
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "OAuth client secret key",
|
||||
required: true,
|
||||
})
|
||||
export class OAuthClientWebhooksController {
|
||||
constructor(
|
||||
private readonly webhooksService: WebhooksService,
|
||||
|
||||
+26
-19
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { GetOrgId } from "@/modules/auth/decorators/get-org-id/get-org-id.decorator";
|
||||
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -10,6 +11,7 @@ import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/o
|
||||
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
|
||||
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -26,10 +28,15 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UseGuards,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ApiCreatedResponse as DocsCreatedResponse, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
ApiCreatedResponse as DocsCreatedResponse,
|
||||
ApiTags,
|
||||
ApiOperation,
|
||||
ApiExcludeEndpoint,
|
||||
ApiHeader,
|
||||
} from "@nestjs/swagger";
|
||||
import { User, MembershipRole } from "@prisma/client";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -41,11 +48,12 @@ import { CreateOAuthClientInput, UpdateOAuthClientInput, Pagination } from "@cal
|
||||
})
|
||||
@ApiTags("OAuth Clients")
|
||||
@UseGuards(ApiAuthGuard, OrganizationRolesGuard)
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OAuthClientsController {
|
||||
private readonly logger = new Logger("OAuthClientController");
|
||||
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService,
|
||||
private readonly oAuthClientsService: OAuthClientsService,
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly teamsRepository: OrganizationsRepository
|
||||
@@ -58,6 +66,7 @@ export class OAuthClientsController {
|
||||
description: "Create an OAuth client",
|
||||
type: CreateOAuthClientResponseDto,
|
||||
})
|
||||
@ApiOperation({ summary: "Create an OAuth client" })
|
||||
async createOAuthClient(
|
||||
@GetOrgId() organizationId: number,
|
||||
@Body() body: CreateOAuthClientInput
|
||||
@@ -68,7 +77,9 @@ export class OAuthClientsController {
|
||||
|
||||
const organization = await this.teamsRepository.findByIdIncludeBilling(organizationId);
|
||||
if (!organization?.platformBilling || !organization?.platformBilling?.subscriptionId) {
|
||||
throw new BadRequestException("Team is not subscribed, cannot create an OAuth Client.");
|
||||
throw new BadRequestException(
|
||||
"Team is not subscribed to platform plan, cannot create an OAuth Client."
|
||||
);
|
||||
}
|
||||
|
||||
const oAuthClientCredentials = await this.oAuthClientsService.createOAuthClient(organizationId, body);
|
||||
@@ -81,6 +92,7 @@ export class OAuthClientsController {
|
||||
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get all OAuth clients" })
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
async getOAuthClients(@GetOrgId() organizationId: number): Promise<GetOAuthClientsResponseDto> {
|
||||
const clients = await this.oAuthClientsService.getOAuthClients(organizationId);
|
||||
@@ -91,6 +103,7 @@ export class OAuthClientsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@UseGuards(OAuthClientGuard)
|
||||
@ApiOperation({ summary: "Get an OAuth client" })
|
||||
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
|
||||
const client = await this.oAuthClientsService.getOAuthClientById(clientId);
|
||||
return { status: SUCCESS_STATUS, data: client };
|
||||
@@ -100,6 +113,9 @@ export class OAuthClientsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
|
||||
@UseGuards(OAuthClientGuard)
|
||||
// note(Lauris): Excluding this endpoint because we have oauth-clients-users.controller to fetch oAuth users and I am leaving this endpoint
|
||||
// just in case someone is using it.
|
||||
@ApiExcludeEndpoint()
|
||||
async getOAuthClientManagedUsersById(
|
||||
@Param("clientId") clientId: string,
|
||||
@Query() queryParams: Pagination
|
||||
@@ -111,13 +127,17 @@ export class OAuthClientsController {
|
||||
limit ?? 50
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: existingManagedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/:clientId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@UseGuards(OAuthClientGuard)
|
||||
@ApiOperation({ summary: "Update an OAuth client" })
|
||||
async updateOAuthClient(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: UpdateOAuthClientInput
|
||||
@@ -131,23 +151,10 @@ export class OAuthClientsController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
@UseGuards(OAuthClientGuard)
|
||||
@ApiOperation({ summary: "Delete an OAuth client" })
|
||||
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
|
||||
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
|
||||
const client = await this.oAuthClientsService.deleteOAuthClient(clientId);
|
||||
return { status: SUCCESS_STATUS, data: client };
|
||||
}
|
||||
|
||||
private getResponseUser(user: User): ManagedUserOutput {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
timeZone: user.timeZone,
|
||||
weekStart: user.weekStart,
|
||||
createdDate: user.createdDate,
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+54
-12
@@ -4,6 +4,7 @@ import { HttpExceptionFilter } from "@/filters/http-exception.filter";
|
||||
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
|
||||
import { ZodExceptionFilter } from "@/filters/zod-exception.filter";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { OAuthAuthorizeInput } from "@/modules/oauth-clients/inputs/authorize.input";
|
||||
import { ExchangeAuthorizationCodeInput } from "@/modules/oauth-clients/inputs/exchange-code.input";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
@@ -68,7 +69,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
|
||||
let authorizationCode: string | null;
|
||||
let refreshToken: string;
|
||||
let responseRefreshToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const userEmail = `oauth-flow-user-${randomString()}@api.com`;
|
||||
@@ -153,29 +154,70 @@ describe("OAuthFlow Endpoints", () => {
|
||||
.send(body)
|
||||
.expect(200);
|
||||
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
refreshToken = response.body.data.refreshToken;
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
describe("Refresh Token Endpoint", () => {
|
||||
it("POST /oauth/:clientId/refresh", () => {
|
||||
it("POST /oauth/:clientId/refresh", async () => {
|
||||
const secretKey = oAuthClient.secret;
|
||||
const body = {
|
||||
refreshToken,
|
||||
refreshToken: responseRefreshToken,
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
|
||||
.set("x-cal-secret-key", secretKey)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
expect(response.body?.data?.accessToken).toBeDefined();
|
||||
expect(response.body?.data?.refreshToken).toBeDefined();
|
||||
});
|
||||
.expect(200);
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } = response.body.data;
|
||||
expect(accessToken).toBeDefined();
|
||||
expect(refreshToken).toBeDefined();
|
||||
expect(accessTokenExpiresAt).toBeDefined();
|
||||
expect(refreshTokenExpiresAt).toBeDefined();
|
||||
|
||||
const jwtService = app.get(JwtService);
|
||||
const decodedAccessToken = jwtService.decode(accessToken);
|
||||
const decodedRefreshToken = jwtService.decode(refreshToken);
|
||||
|
||||
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedAccessToken.ownerId).toBe(user.id);
|
||||
expect(decodedAccessToken.userId).toBe(user.id);
|
||||
expect(decodedAccessToken.type).toBe("access_token");
|
||||
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
|
||||
expect(decodedAccessToken.iat).toBeGreaterThan(0);
|
||||
|
||||
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
|
||||
expect(decodedRefreshToken.ownerId).toBe(user.id);
|
||||
expect(decodedRefreshToken.userId).toBe(user.id);
|
||||
expect(decodedRefreshToken.type).toBe("refresh_token");
|
||||
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
|
||||
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
|
||||
|
||||
responseRefreshToken = refreshToken;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-28
@@ -1,4 +1,3 @@
|
||||
import { getEnv } from "@/env";
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
@@ -25,11 +24,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiOperation as DocsOperation,
|
||||
ApiOkResponse as DocsOkResponse,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
@@ -37,6 +32,9 @@ import { Response as ExpressResponse } from "express";
|
||||
|
||||
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
|
||||
export const TOKENS_DOCS = `Access token is valid for 60 minutes and refresh token for 1 year. Make sure to store them in your database, for example, in your User database model \`calAccessToken\` and \`calRefreshToken\` fields.
|
||||
Response also contains \`accessTokenExpiresAt\` and \`refreshTokenExpiresAt\` fields, but if you decode the jwt token the payload will contain \`clientId\` (OAuth client ID), \`ownerId\` (user to whom token belongs ID), \`iat\` (issued at time) and \`expiresAt\` (when does the token expire) fields.`;
|
||||
|
||||
@Controller({
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -96,20 +94,15 @@ export class OAuthFlowController {
|
||||
throw new BadRequestException("Missing 'Bearer' Authorization header.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } =
|
||||
await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.exchangeAuthorizationToken(
|
||||
authorizeEndpointCode,
|
||||
clientId,
|
||||
body.clientSecret
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -124,27 +117,19 @@ export class OAuthFlowController {
|
||||
})
|
||||
@ApiOperation({
|
||||
summary: "Refresh managed user tokens",
|
||||
description: `If managed user access token is expired then get a new one using this endpoint. Each access token is valid for 60 minutes and
|
||||
each refresh token for 1 year. Make sure to store them later in your database, for example, by updating the User model to have \`calAccessToken\` and \`calRefreshToken\` columns.`,
|
||||
description: `If managed user access token is expired then get a new one using this endpoint - it will also refresh the refresh token, because we use
|
||||
"refresh token rotation" mechanism. ${TOKENS_DOCS}`,
|
||||
})
|
||||
async refreshTokens(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
): Promise<KeysResponseDto> {
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.oAuthFlowService.refreshToken(
|
||||
clientId,
|
||||
secretKey,
|
||||
body.refreshToken
|
||||
);
|
||||
const tokens = await this.oAuthFlowService.refreshToken(clientId, secretKey, body.refreshToken);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: {
|
||||
accessToken: accessToken,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
data: tokens,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,7 +4,7 @@ import { ValidateNested, IsEnum, IsString, IsNotEmptyObject, IsNumber } from "cl
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
class KeysDto {
|
||||
export class KeysDto {
|
||||
@ApiProperty({
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
|
||||
})
|
||||
@@ -19,6 +19,9 @@ class KeysDto {
|
||||
|
||||
@IsNumber()
|
||||
accessTokenExpiresAt!: number;
|
||||
|
||||
@IsNumber()
|
||||
refreshTokenExpiresAt!: number;
|
||||
}
|
||||
|
||||
export class KeysResponseDto {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/
|
||||
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
|
||||
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
|
||||
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
|
||||
import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service";
|
||||
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
|
||||
@@ -58,8 +59,9 @@ import { JwtService } from "@nestjs/jwt";
|
||||
OAuthClientsInputService,
|
||||
OAuthClientsOutputService,
|
||||
JwtService,
|
||||
OAuthClientUsersOutputService,
|
||||
],
|
||||
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
|
||||
exports: [OAuthClientRepository],
|
||||
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
|
||||
})
|
||||
export class OAuthClientModule {}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { User } from "@prisma/client";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
@Injectable()
|
||||
export class OAuthClientUsersOutputService {
|
||||
getResponseUser(user: User) {
|
||||
return plainToInstance(ManagedUserOutput, user, { strategy: "excludeAll" });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/services/event-types.service";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
|
||||
@@ -41,7 +42,7 @@ export class OAuthClientUsersService {
|
||||
);
|
||||
} else {
|
||||
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
|
||||
user = (
|
||||
const createdUser = (
|
||||
await createNewUsersConnectToOrgIfExists({
|
||||
invitations: [
|
||||
{
|
||||
@@ -64,23 +65,21 @@ export class OAuthClientUsersService {
|
||||
timeFormat: body.timeFormat,
|
||||
weekStart: body.weekStart,
|
||||
timeZone: body.timeZone,
|
||||
language: body.locale ?? Locales.EN,
|
||||
})
|
||||
)[0];
|
||||
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
|
||||
const updatedUser = await this.userRepository.update(user.id, {
|
||||
await this.userRepository.addToOAuthClient(createdUser.id, oAuthClientId);
|
||||
user = await this.userRepository.update(createdUser.id, {
|
||||
name: body.name,
|
||||
locale: body.locale,
|
||||
avatarUrl: body.avatarUrl,
|
||||
bio: body.bio,
|
||||
metadata: body.metadata,
|
||||
});
|
||||
user.locale = updatedUser.locale;
|
||||
user.name = updatedUser.name;
|
||||
user.avatarUrl = updatedUser.avatarUrl;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
oAuthClientId,
|
||||
user.id
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(oAuthClientId, user.id);
|
||||
|
||||
if (oAuthClient.areDefaultEventTypesEnabled) {
|
||||
await this.eventTypesService.createUserDefaultEventTypes(user.id);
|
||||
@@ -104,6 +103,7 @@ export class OAuthClientUsersService {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
|
||||
import {
|
||||
KeysDto,
|
||||
KeysResponseDto,
|
||||
} from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
@@ -103,7 +107,7 @@ export class OAuthFlowService {
|
||||
tokenId: string,
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
): Promise<{ accessToken: string; refreshToken: string; accessTokenExpiresAt: Date }> {
|
||||
): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithAuthTokens(
|
||||
tokenId,
|
||||
clientId,
|
||||
@@ -120,21 +124,20 @@ export class OAuthFlowService {
|
||||
throw new BadRequestException("Invalid Authorization Token.");
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
clientId,
|
||||
authorizationToken.owner.id
|
||||
);
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
|
||||
await this.tokensRepository.createOAuthTokens(clientId, authorizationToken.owner.id);
|
||||
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
|
||||
void this.propagateAccessToken(accessToken); // void result, ignored.
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
accessTokenExpiresAt,
|
||||
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
|
||||
refreshToken,
|
||||
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string) {
|
||||
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string): Promise<KeysDto> {
|
||||
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
|
||||
clientId,
|
||||
clientSecret,
|
||||
@@ -159,8 +162,9 @@ export class OAuthFlowService {
|
||||
|
||||
return {
|
||||
accessToken: accessToken.secret,
|
||||
accessTokenExpiresAt: accessToken.expiresAt,
|
||||
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
|
||||
refreshToken: refreshToken.secret,
|
||||
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -28,7 +29,7 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
@@ -39,6 +40,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Attributes")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsAttributesController {
|
||||
constructor(private readonly organizationsAttributesService: OrganizationAttributesService) {}
|
||||
// Gets all attributes for an organization
|
||||
|
||||
+3
-1
@@ -1,4 +1,5 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_HEADER } from "@/lib/docs/headers";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -20,7 +21,7 @@ import { GetAllAttributeOptionOutput } from "@/modules/organizations/attributes/
|
||||
import { UpdateAttributeOptionOutput } from "@/modules/organizations/attributes/options/outputs/update-option.output";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -30,6 +31,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@DocsTags("Orgs / Attributes / Options")
|
||||
@ApiHeader(API_KEY_HEADER)
|
||||
export class OrganizationsAttributesOptionsController {
|
||||
constructor(private readonly organizationsAttributesOptionsService: OrganizationAttributeOptionService) {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user