Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0466b7ebd6 | ||
|
|
09329707c1 | ||
|
|
1773aba378 | ||
|
|
f8be2b3833 | ||
|
|
79d488faca | ||
|
|
da2c57c302 | ||
|
|
abaf03adde | ||
|
|
5b353c3d0c | ||
|
|
1f4cda31ac | ||
|
|
73d1a6f468 | ||
|
|
f72748b6f2 | ||
|
|
d12fc78fea | ||
|
|
3d26332443 | ||
|
|
bdb490c894 | ||
|
|
01dbc686b9 | ||
|
|
376961f30d | ||
|
|
18a0eb9736 | ||
|
|
84fb4f68b2 | ||
|
|
b5ab6fe3fa | ||
|
|
645985c783 | ||
|
|
a84513eda5 | ||
|
|
b97331aeb3 | ||
|
|
851d96423b | ||
|
|
ef830f11ec | ||
|
|
1ab4ec9c22 | ||
|
|
656a5bf423 | ||
|
|
8a6450e854 | ||
|
|
51841e5e9c | ||
|
|
59dfcaa2fa | ||
|
|
0d24706380 | ||
|
|
87b6e0b49c | ||
|
|
4244ca3117 | ||
|
|
8d0f0d4899 | ||
|
|
6463983d55 | ||
|
|
c8661563c8 | ||
|
|
2262d56491 | ||
|
|
6b1c747c0e | ||
|
|
04036c32a7 | ||
|
|
49070b96ae | ||
|
|
237fea0a41 | ||
|
|
a7e7561efa | ||
|
|
e366c10521 | ||
|
|
2ace1d1017 | ||
|
|
fbe31ad82d | ||
|
|
ee3c08918a | ||
|
|
b6d6b7cdb4 | ||
|
|
1b82552e76 | ||
|
|
13a90aba30 | ||
|
|
de0de64a16 | ||
|
|
8e772d9046 | ||
|
|
7ac7a14beb | ||
|
|
9c127cbfc2 | ||
|
|
187a2c576e | ||
|
|
5e8d994ac1 | ||
|
|
1de007ad21 | ||
|
|
06494a6999 | ||
|
|
016c47d535 | ||
|
|
2aeb290bda | ||
|
|
95b4f67842 | ||
|
|
1a4afe06c8 | ||
|
|
0eb72d66a7 | ||
|
|
7d2523e823 | ||
|
|
6739ed3a87 | ||
|
|
dd3505ad8b | ||
|
|
ce9df65b5d | ||
|
|
fc6e39c78a | ||
|
|
931f3d3396 | ||
|
|
8a6fac6748 | ||
|
|
af43ede38e | ||
|
|
a66eb9dfac | ||
|
|
5ba676b014 | ||
|
|
c2c082c105 | ||
|
|
44d6c1f3fb | ||
|
|
a466e8eef7 | ||
|
|
370345da22 | ||
|
|
a1fc6668d5 | ||
|
|
351ab0790f | ||
|
|
504600b151 | ||
|
|
8b222490c2 | ||
|
|
0657a96795 | ||
|
|
9c9d7fe0c3 | ||
|
|
950879b9c7 | ||
|
|
55dc4bddb6 | ||
|
|
cbb1b285f5 | ||
|
|
45f8ecbab7 | ||
|
|
1680cba5a5 | ||
|
|
046042a877 | ||
|
|
a926e0b9d6 | ||
|
|
9334b67f2c | ||
|
|
ae1b844bea | ||
|
|
15e93af2ac | ||
|
|
49c97c6380 | ||
|
|
8679d21d78 | ||
|
|
382f00e178 | ||
|
|
e8eab10d17 | ||
|
|
7a7282a7ab | ||
|
|
19b3d6a633 | ||
|
|
046b7a0d08 | ||
|
|
30acf6b196 | ||
|
|
5e95018870 | ||
|
|
e559e7a46f | ||
|
|
3b1123a29a | ||
|
|
be3e580107 | ||
|
|
b325cf8d00 | ||
|
|
6ce9defecf | ||
|
|
8d05656b7b | ||
|
|
091400b7d5 | ||
|
|
6de26b3c43 | ||
|
|
063400fcbc | ||
|
|
44115ac7fa | ||
|
|
7e76770ba6 | ||
|
|
d5ef3e1d72 | ||
|
|
0c19606e19 | ||
|
|
b8219c273d | ||
|
|
f75415e414 | ||
|
|
4bf5e3cdf5 | ||
|
|
4181b5988a | ||
|
|
c41ba9d984 | ||
|
|
2a578ea9d5 | ||
|
|
c96827e1c0 | ||
|
|
3f5363038c | ||
|
|
e2de28b933 | ||
|
|
5d88858995 | ||
|
|
82a500aeaa | ||
|
|
2408337c78 | ||
|
|
119edb5389 | ||
|
|
ae4474cd42 | ||
|
|
3290ae526a | ||
|
|
16a7cf6d04 | ||
|
|
ab2ab31eef | ||
|
|
a08ae43533 | ||
|
|
d60b6c6a6d | ||
|
|
9085287fc6 | ||
|
|
7e2b5b12b4 | ||
|
|
3211c5bcff | ||
|
|
a75a5b72b2 | ||
|
|
509fc4b289 | ||
|
|
f63c06d68a | ||
|
|
3dff77fe59 | ||
|
|
a6f9952d75 | ||
|
|
03704b2e73 | ||
|
|
982a715c4b | ||
|
|
eaff9e8c89 | ||
|
|
e2938ac39a | ||
|
|
b92db55380 | ||
|
|
3015c5a15e | ||
|
|
2444598e38 | ||
|
|
6eadadec73 | ||
|
|
145449e7b4 | ||
|
|
1d8326ed83 | ||
|
|
59993891b8 | ||
|
|
a31d140761 | ||
|
|
b055fe182a | ||
|
|
392757fb55 | ||
|
|
3222be9171 | ||
|
|
6378729962 | ||
|
|
1c5786b66f | ||
|
|
e61a0d8081 | ||
|
|
0cb955cc0b | ||
|
|
00689fda0a | ||
|
|
bd83b36b75 | ||
|
|
05e2029bc9 | ||
|
|
9e3d369ac3 | ||
|
|
356c603e3f | ||
|
|
5afe3aa54d | ||
|
|
985f216a43 | ||
|
|
3a472997ff | ||
|
|
6dcb6aece4 | ||
|
|
dc7ee1aba2 | ||
|
|
2f9f29511b | ||
|
|
0aa58c1095 | ||
|
|
58b77b2a16 | ||
|
|
caeaf9053b | ||
|
|
fe209df2be | ||
|
|
2e7a5a84a6 | ||
|
|
410558f822 | ||
|
|
a3ceefc00e | ||
|
|
c524a3c1ef | ||
|
|
a5ba2682f6 | ||
|
|
51c91e41da | ||
|
|
cf6654dd1f | ||
|
|
d057d7d37f | ||
|
|
262738f288 | ||
|
|
27cd2457e8 | ||
|
|
b33ca2f937 | ||
|
|
82f64f7d6a | ||
|
|
8052d27d0b | ||
|
|
5720ec8038 | ||
|
|
01b1f5bcc8 | ||
|
|
953a8119db | ||
|
|
e8daa4f92f | ||
|
|
f631abfdc4 |
+6
-11
@@ -119,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
|
||||
|
||||
# Google OAuth credentials
|
||||
# To enable Login with Google you need to:
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` above
|
||||
# 1. Set `GOOGLE_API_CREDENTIALS` below
|
||||
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
|
||||
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
|
||||
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
|
||||
@@ -239,7 +239,8 @@ CLOUDFLARE_TURNSTILE_SECRET=
|
||||
NEXT_PUBLIC_TEAM_IMPERSONATION=false
|
||||
|
||||
# Close.com internal CRM
|
||||
CLOSECOM_API_KEY=
|
||||
CLOSECOM_CLIENT_ID=
|
||||
CLOSECOM_CLIENT_SECRET=
|
||||
|
||||
# Sendgrid internal sync service
|
||||
SENDGRID_SYNC_API_KEY=
|
||||
@@ -347,7 +348,6 @@ E2E_TEST_OIDC_USER_PASSWORD=
|
||||
AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_ENABLED=0
|
||||
APP_ROUTER_APPS_SLUG_SETUP_ENABLED=0
|
||||
@@ -357,14 +357,10 @@ APP_ROUTER_APPS_CATEGORIES_ENABLED=0
|
||||
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
|
||||
APP_ROUTER_BOOKING_ENABLED=0
|
||||
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
|
||||
APP_ROUTER_WORKFLOWS_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_TEAMS_ENABLED=0
|
||||
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
|
||||
APP_ROUTER_APPS_ENABLED=0
|
||||
APP_ROUTER_VIDEO_ENABLED=0
|
||||
APP_ROUTER_TEAM_ENABLED=0
|
||||
APP_ROUTER_TEAMS_ENABLED=0
|
||||
APP_ROUTER_AVAILABILITY_ENABLED=0
|
||||
APP_ROUTER_AUTH_FORGOT_PASSWORD_ENABLED=0
|
||||
APP_ROUTER_AUTH_LOGIN_ENABLED=0
|
||||
APP_ROUTER_AUTH_LOGOUT_ENABLED=0
|
||||
@@ -373,10 +369,6 @@ APP_ROUTER_AUTH_SAML_ENABLED=0
|
||||
APP_ROUTER_AUTH_ERROR_ENABLED=0
|
||||
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
|
||||
APP_ROUTER_MORE_ENABLED=0
|
||||
APP_ROUTER_MAINTENANCE_ENABLED=0
|
||||
APP_ROUTER_UPGRADE_ENABLED=0
|
||||
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
|
||||
|
||||
# disable setry server source maps
|
||||
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
|
||||
@@ -416,3 +408,6 @@ NEXT_PUBLIC_LOGGER_LEVEL=
|
||||
|
||||
# Used to use Replexica SDK, a tool for real-time AI-powered localization
|
||||
REPLEXICA_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=
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/packages/app-store/applecalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/exchange2016calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/exchangecalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/feishucalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/googlecalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/ics-feedcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/larkcalendar/**/* @calcom/Foundation
|
||||
/packages/app-store/office365calendar/**/* @calcom/Foundation
|
||||
/packages/app-store/zohocalendar/**/* @calcom/Foundation
|
||||
@@ -17,6 +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 }}
|
||||
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_API_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
|
||||
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
|
||||
|
||||
@@ -17,7 +17,6 @@ env:
|
||||
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
|
||||
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
|
||||
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
|
||||
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
|
||||
@@ -51,7 +50,8 @@ env:
|
||||
jobs:
|
||||
analyze:
|
||||
if: always()
|
||||
runs-on: buildjet-2vcpu-ubuntu-2204
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/dangerous-git-checkout
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
- name: Analyze bundle
|
||||
run: |
|
||||
cd apps/web
|
||||
export NODE_OPTIONS="--max_old_space_size=8192"
|
||||
npx -p nextjs-bundle-analysis@0.5.0 report
|
||||
|
||||
- name: Upload bundle
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
printf '{
|
||||
"pr_number": ${{ github.event.pull_request.number }}
|
||||
}' >> context.json
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: context.json
|
||||
path: ./
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
name: Unit
|
||||
on:
|
||||
workflow_call:
|
||||
env:
|
||||
REPLEXICA_API_KEY: ${{ secrets.CI_REPLEXICA_API_KEY }}
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
|
||||
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
Vendored
Vendored
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
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
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
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
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
+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
+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"
|
||||
@@ -6,6 +6,7 @@ import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
import { isLockedOrBlocked } from "../utils/isLockedOrBlocked";
|
||||
import { ScopeOfAdmin } from "../utils/scopeOfAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
@@ -24,23 +25,35 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => {
|
||||
if (!hasValidLicense && IS_PRODUCTION) {
|
||||
return res.status(401).json({ error: "Invalid or missing CALCOM_LICENSE_KEY environment variable" });
|
||||
}
|
||||
// Check if the apiKey query param is provided.
|
||||
|
||||
if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" });
|
||||
// remove the prefix from the user provided api_key. If no env set default to "cal_"
|
||||
|
||||
const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", "");
|
||||
// Hash the key again before matching against the database records.
|
||||
const hashedKey = hashAPIKey(strippedApiKey);
|
||||
// Check if the hashed api key exists in database.
|
||||
const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } });
|
||||
// If cannot find any api key. Throw a 401 Unauthorized.
|
||||
if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" });
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { hashedKey },
|
||||
include: {
|
||||
user: {
|
||||
select: { role: true, locked: true, email: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!apiKey) return res.status(401).json({ error: "Your API key is not valid." });
|
||||
if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) {
|
||||
return res.status(401).json({ error: "This apiKey is expired" });
|
||||
return res.status(401).json({ error: "This API key is expired." });
|
||||
}
|
||||
if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" });
|
||||
if (!apiKey.userId || !apiKey.user)
|
||||
return res.status(404).json({ error: "No user found for this API key." });
|
||||
|
||||
// save the user id in the request for later use
|
||||
req.userId = apiKey.userId;
|
||||
req.user = apiKey.user;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
const userIsLockedOrBlocked = await isLockedOrBlocked(req);
|
||||
|
||||
if (userIsLockedOrBlocked)
|
||||
return res.status(403).json({ error: "You are not authorized to perform this request." });
|
||||
|
||||
req.isSystemWideAdmin = isAdmin && scope === ScopeOfAdmin.SystemWide;
|
||||
req.isOrganizationOwnerOrAdmin = isAdmin && scope === ScopeOfAdmin.OrgOwnerOrAdmin;
|
||||
|
||||
@@ -6,8 +6,7 @@ import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
|
||||
import { ScopeOfAdmin } from "./scopeOfAdmin";
|
||||
|
||||
export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
const { userId } = req;
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: { role: true } });
|
||||
const { user, userId } = req;
|
||||
if (!user) return { isAdmin: false, scope: null };
|
||||
|
||||
const { role: userRole } = user;
|
||||
@@ -34,7 +33,7 @@ export const isAdminGuard = async (req: NextApiRequest) => {
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!orgOwnerOrAdminMemberships.length) return { isAdmin: false, scope: null };
|
||||
if (orgOwnerOrAdminMemberships.length > 0) return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
|
||||
|
||||
return { isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin };
|
||||
return { isAdmin: false, scope: null };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { checkIfEmailInWatchlistController } from "@calcom/features/watchlist/operations/check-if-email-in-watchlist.controller";
|
||||
|
||||
export async function isLockedOrBlocked(req: NextApiRequest) {
|
||||
const user = req.user;
|
||||
if (!user?.email) return false;
|
||||
return user.locked || (await checkIfEmailInWatchlistController(user.email));
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const schemaBookingBaseBodyParams = Booking.pick({
|
||||
status: true,
|
||||
rescheduledBy: true,
|
||||
cancelledBy: true,
|
||||
createdAt: true,
|
||||
}).partial();
|
||||
|
||||
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
||||
@@ -119,4 +120,5 @@ export const schemaBookingReadPublic = Booking.extend({
|
||||
fromReschedule: true,
|
||||
cancelledBy: true,
|
||||
rescheduledBy: true,
|
||||
createdAt: true,
|
||||
});
|
||||
|
||||
Vendored
+1
@@ -8,6 +8,7 @@ export declare module "next" {
|
||||
session?: Session | null;
|
||||
|
||||
userId: number;
|
||||
user?: { role: string; locked: boolean; email: string } | null;
|
||||
method: string;
|
||||
// session: { user: { id: number } };
|
||||
// query: Partial<{ [key: string]: string | string[] }>;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import type { Request, Response } from "express";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, vi, it, expect, afterEach, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import LicenseKeyService from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
import { hashAPIKey } from "~/../../../packages/features/ee/api-keys/lib/apiKeys";
|
||||
|
||||
import { verifyApiKey } from "../../../lib/helpers/verifyApiKey";
|
||||
import { ScopeOfAdmin } from "../../../lib/utils/scopeOfAdmin";
|
||||
|
||||
type CustomNextApiRequest = NextApiRequest & Request;
|
||||
type CustomNextApiResponse = NextApiResponse & Response;
|
||||
@@ -20,11 +20,6 @@ afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
vi.mock("@calcom/prisma");
|
||||
vi.mock("~/lib/utils/isAdmin", () => ({
|
||||
isAdminGuard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Verify API key", () => {
|
||||
let service: LicenseKeyService;
|
||||
|
||||
@@ -45,7 +40,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(false);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -69,7 +63,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: false, scope: null });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -91,10 +84,18 @@ describe("Verify API key", () => {
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
|
||||
prismaMock.apiKey.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 2,
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "admin@example.com",
|
||||
role: UserPermissionRole.ADMIN,
|
||||
locked: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
@@ -102,7 +103,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.SystemWide });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -125,10 +125,36 @@ describe("Verify API key", () => {
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
|
||||
prismaMock.apiKey.findUnique.mockResolvedValue({
|
||||
id: 1,
|
||||
userId: 2,
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "org-admin@acme.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: false,
|
||||
teams: {
|
||||
create: {
|
||||
accepted: true,
|
||||
role: MembershipRole.OWNER,
|
||||
team: {
|
||||
create: {
|
||||
name: "ACME",
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
create: {
|
||||
isAdminAPIEnabled: true,
|
||||
orgAutoAcceptEmail: "acme.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
@@ -136,7 +162,6 @@ describe("Verify API key", () => {
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
vi.mocked(isAdminGuard).mockResolvedValue({ isAdmin: true, scope: ScopeOfAdmin.OrgOwnerOrAdmin });
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
|
||||
@@ -149,4 +174,44 @@ describe("Verify API key", () => {
|
||||
expect(req.isSystemWideAdmin).toBe(false);
|
||||
expect(req.isOrganizationOwnerOrAdmin).toBe(true);
|
||||
});
|
||||
|
||||
it("should return 403 if user is locked or blocked", async () => {
|
||||
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
|
||||
method: "POST",
|
||||
body: {},
|
||||
query: {
|
||||
apiKey: "cal_test_key",
|
||||
},
|
||||
prisma,
|
||||
});
|
||||
const hashedKey = hashAPIKey("test_key");
|
||||
await prismock.apiKey.create({
|
||||
data: {
|
||||
hashedKey,
|
||||
user: {
|
||||
create: {
|
||||
email: "locked@example.com",
|
||||
role: UserPermissionRole.USER,
|
||||
locked: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const middleware = {
|
||||
fn: verifyApiKey,
|
||||
};
|
||||
|
||||
vi.mocked(service.checkLicense).mockResolvedValue(true);
|
||||
|
||||
const serverNext = vi.fn((next: void) => Promise.resolve(next));
|
||||
const middlewareSpy = vi.spyOn(middleware, "fn");
|
||||
|
||||
await middleware.fn(req, res, serverNext);
|
||||
|
||||
expect(middlewareSpy).toBeCalled();
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res._getData())).toEqual({ error: "You are not authorized to perform this request." });
|
||||
expect(serverNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ describe("isAdmin guard", () => {
|
||||
});
|
||||
|
||||
req.userId = 0;
|
||||
req.user = undefined;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -35,6 +36,7 @@ describe("isAdmin guard", () => {
|
||||
const memberUser = await prisma.user.findFirstOrThrow({ where: { email: "member2-acme@example.com" } });
|
||||
|
||||
req.userId = memberUser.id;
|
||||
req.user = memberUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -51,6 +53,7 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "admin@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
|
||||
@@ -67,6 +70,8 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin, scope } = await isAdminGuard(req);
|
||||
expect(isAdmin).toBe(true);
|
||||
expect(scope).toBe(ScopeOfAdmin.OrgOwnerOrAdmin);
|
||||
@@ -81,6 +86,8 @@ describe("isAdmin guard", () => {
|
||||
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-dunder@example.com" } });
|
||||
|
||||
req.userId = adminUser.id;
|
||||
req.user = adminUser;
|
||||
|
||||
const { isAdmin } = await isAdminGuard(req);
|
||||
expect(isAdmin).toBe(false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
|
||||
import { isLockedOrBlocked } from "../../../lib/utils/isLockedOrBlocked";
|
||||
|
||||
describe("isLockedOrBlocked", () => {
|
||||
beforeEach(async () => {
|
||||
await prismock.watchlist.createMany({
|
||||
data: [
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "spam.com",
|
||||
createdById: 1,
|
||||
},
|
||||
{
|
||||
type: "DOMAIN",
|
||||
value: "blocked.com",
|
||||
createdById: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should return false if no user in request", async () => {
|
||||
const req = { userId: null, user: null } as any;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false if user has no email", async () => {
|
||||
const req = { userId: 123, user: { email: null } } as any;
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true if user is locked", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: true,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true if user email domain is watchlisted", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@blocked.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if user is not locked and email domain is not watchlisted", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@example.com",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle email domains case-insensitively", async () => {
|
||||
const req = {
|
||||
userId: 123,
|
||||
user: {
|
||||
locked: false,
|
||||
email: "test@BLOCKED.COM",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const result = await isLockedOrBlocked(req);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"./next.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"../../../packages/types/*.d.ts",
|
||||
|
||||
@@ -6,6 +6,7 @@ ARG DATABASE_URL
|
||||
WORKDIR /calcom
|
||||
|
||||
RUN set -eux;
|
||||
RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
ENV NODE_OPTIONS="--max-old-space-size=8192"
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.63",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.72",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -169,6 +169,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
|
||||
+8
-1
@@ -34,6 +34,7 @@ import {
|
||||
OrganizerCancelledEmail,
|
||||
AttendeeCancelledEmail,
|
||||
OrganizerReassignedEmail,
|
||||
AttendeeUpdatedEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
@@ -61,10 +62,12 @@ jest
|
||||
jest
|
||||
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
jest
|
||||
.spyOn(OrganizerReassignedEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
jest
|
||||
.spyOn(AttendeeUpdatedEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
type EmailSetup = {
|
||||
team: Team;
|
||||
@@ -681,6 +684,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
|
||||
});
|
||||
});
|
||||
@@ -697,6 +701,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(AttendeeUpdatedEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -898,6 +903,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
|
||||
});
|
||||
});
|
||||
@@ -914,6 +920,7 @@ describe("Bookings Endpoints 2024-08-13 team emails", () => {
|
||||
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
|
||||
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
expect(AttendeeUpdatedEmail.prototype.getHtml).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -181,6 +181,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -201,6 +202,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -256,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -277,6 +280,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -296,6 +300,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(thirdBooking.recurringBookingUid).toBeDefined();
|
||||
expect(thirdBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
|
||||
@@ -205,6 +205,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -278,6 +279,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne);
|
||||
expect(firstAttendee).toEqual({
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
email: createdSeatedBooking.attendees[0].email,
|
||||
timeZone: createdSeatedBooking.attendees[0].timeZone,
|
||||
language: createdSeatedBooking.attendees[0].language,
|
||||
absent: false,
|
||||
@@ -291,6 +293,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
|
||||
expect(secondAttendee).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -368,6 +371,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.attendees.length).toEqual(1);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -402,6 +406,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.attendees.length).toEqual(1);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -576,6 +581,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: attendee?.name,
|
||||
email: attendee?.email,
|
||||
timeZone: attendee?.timeZone,
|
||||
language: attendee?.language,
|
||||
absent: false,
|
||||
@@ -835,6 +841,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.attendees.length).toEqual(1);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -869,6 +876,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.attendees.length).toEqual(1);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -946,6 +954,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.attendees.length).toEqual(2);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -958,6 +967,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
expect(firstBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -992,6 +1002,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.attendees.length).toEqual(2);
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: nameAttendeeOne,
|
||||
email: emailAttendeeOne,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -1004,6 +1015,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
expect(secondBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
email: emailAttendeeTwo,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
|
||||
@@ -307,6 +307,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.attendees.length).toEqual(1);
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -359,12 +360,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.attendees.length).toEqual(2);
|
||||
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.attendees[1]).toEqual({
|
||||
name: teamUser2.name,
|
||||
email: teamUser2.email,
|
||||
timeZone: teamUser2.timeZone,
|
||||
language: teamUser2.locale,
|
||||
absent: false,
|
||||
|
||||
@@ -364,6 +364,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -434,6 +435,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
|
||||
expect(firstBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -455,6 +457,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.recurringBookingUid).toBeDefined();
|
||||
expect(secondBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
@@ -475,6 +478,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(thirdBooking.recurringBookingUid).toBeDefined();
|
||||
expect(thirdBooking.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
@ApiExtraModels(
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13
|
||||
)
|
||||
export class CancelBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
|
||||
@@ -10,7 +10,12 @@ import {
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
@ApiExtraModels(
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13
|
||||
)
|
||||
export class CreateBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
@@ -21,6 +26,8 @@ export class CreateBookingOutput_2024_08_13 {
|
||||
oneOf: [
|
||||
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
|
||||
{ $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) },
|
||||
{ type: "array", items: { $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) } },
|
||||
],
|
||||
description:
|
||||
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
|
||||
@ApiExtraModels(
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13
|
||||
)
|
||||
export class RescheduleBookingOutput_2024_08_13 {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
|
||||
@@ -363,12 +363,17 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
|
||||
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
|
||||
const bookingBefore = await this.bookingsRepository.getByUid(bookingUid);
|
||||
const platformClientParams = bookingBefore?.eventTypeId
|
||||
? await this.inputService.getOAuthClientParams(bookingBefore.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
await handleMarkNoShow({
|
||||
bookingUid,
|
||||
attendees: bodyTransformed.attendees,
|
||||
noShowHost: bodyTransformed.noShowHost,
|
||||
userId: bookingOwnerId,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
@@ -423,7 +428,11 @@ export class BookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(requestUser);
|
||||
|
||||
@@ -431,6 +440,7 @@ export class BookingsService_2024_08_13 {
|
||||
bookingId: booking.id,
|
||||
orgId: profile?.organizationId || null,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
|
||||
@@ -441,12 +451,6 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
}
|
||||
|
||||
async getEmailsEnabled(eventTypeId: number) {
|
||||
const oAuthParams = await this.inputService.getOAuthClientParams(eventTypeId);
|
||||
const emailsEnabled = oAuthParams ? oAuthParams.arePlatformEmailsEnabled : true;
|
||||
return emailsEnabled;
|
||||
}
|
||||
|
||||
async reassignBookingToUser(
|
||||
bookingUid: string,
|
||||
newUserId: number,
|
||||
@@ -463,7 +467,11 @@ export class BookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
|
||||
}
|
||||
|
||||
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const profile = this.usersService.getUserMainProfile(user);
|
||||
|
||||
@@ -474,6 +482,7 @@ export class BookingsService_2024_08_13 {
|
||||
reassignReason: body.reason,
|
||||
reassignedById,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
});
|
||||
|
||||
return this.outputService.getOutputReassignedBooking(reassigned);
|
||||
@@ -485,7 +494,11 @@ export class BookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
@@ -496,6 +509,7 @@ export class BookingsService_2024_08_13 {
|
||||
confirmed: true,
|
||||
recurringEventId: booking.recurringEventId,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -508,7 +522,11 @@ export class BookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
}
|
||||
|
||||
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.inputService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
await confirmBookingHandler({
|
||||
ctx: {
|
||||
@@ -520,6 +538,7 @@ export class BookingsService_2024_08_13 {
|
||||
recurringEventId: booking.recurringEventId,
|
||||
reason,
|
||||
emailsEnabled,
|
||||
platformClientParams,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import {
|
||||
bookingResponsesSchema,
|
||||
seatedBookingResponsesSchema,
|
||||
seatedBookingDataSchema,
|
||||
} from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
@@ -340,7 +340,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
|
||||
}
|
||||
|
||||
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
|
||||
const { responses: bookingResponses } = seatedBookingDataSchema.parse(seat.data);
|
||||
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
|
||||
|
||||
if (!attendee) {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import {
|
||||
defaultBookingMetadata,
|
||||
defaultBookingResponses,
|
||||
defaultSeatedBookingData,
|
||||
defaultSeatedBookingMetadata,
|
||||
} from "@/lib/safe-parse/default-responses-booking";
|
||||
import { safeParse } from "@/lib/safe-parse/safe-parse";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { DateTime } from "luxon";
|
||||
@@ -30,9 +37,10 @@ export const bookingResponsesSchema = z
|
||||
guests: z.array(z.string()).optional(),
|
||||
rescheduleReason: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.describe("BookingResponses");
|
||||
|
||||
export const seatedBookingResponsesSchema = z
|
||||
export const seatedBookingDataSchema = z
|
||||
.object({
|
||||
responses: z
|
||||
.object({
|
||||
@@ -47,7 +55,10 @@ export const seatedBookingResponsesSchema = z
|
||||
})
|
||||
.passthrough(),
|
||||
})
|
||||
.passthrough();
|
||||
.passthrough()
|
||||
.describe("SeatedBookingData");
|
||||
|
||||
const seatedBookingMetadataSchema = z.object({}).catchall(z.string()).describe("SeatedBookingMetadata");
|
||||
|
||||
type DatabaseUser = { id: number; name: string | null; email: string; username: string | null };
|
||||
|
||||
@@ -72,8 +83,6 @@ type BookingWithUser = Booking & { user: DatabaseUser | null };
|
||||
|
||||
type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
|
||||
|
||||
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
|
||||
|
||||
@Injectable()
|
||||
export class OutputBookingsService_2024_08_13 {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
@@ -82,8 +91,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
|
||||
const bookingResponses = safeParse(
|
||||
bookingResponsesSchema,
|
||||
databaseBooking.responses,
|
||||
defaultBookingResponses
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
|
||||
const booking = {
|
||||
@@ -167,8 +180,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
|
||||
const bookingResponses = safeParse(
|
||||
bookingResponsesSchema,
|
||||
databaseBooking.responses,
|
||||
defaultBookingResponses
|
||||
);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
|
||||
const booking = {
|
||||
@@ -225,7 +242,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
|
||||
const booking = {
|
||||
@@ -254,7 +271,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
@@ -267,7 +288,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
@@ -320,7 +345,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
|
||||
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
|
||||
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
|
||||
const location = metadata?.videoCallUrl || databaseBooking.location;
|
||||
|
||||
const booking = {
|
||||
@@ -353,7 +378,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
|
||||
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
|
||||
parsed.attendees = databaseBooking.attendees.map((attendee) => {
|
||||
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
|
||||
const { responses } = safeParse(
|
||||
seatedBookingDataSchema,
|
||||
attendee.bookingSeat?.data,
|
||||
defaultSeatedBookingData
|
||||
);
|
||||
|
||||
const attendeeData = {
|
||||
name: attendee.name,
|
||||
@@ -366,7 +395,11 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||
attendeeParsed.metadata = safeParse(
|
||||
seatedBookingMetadataSchema,
|
||||
attendee.bookingSeat?.metadata,
|
||||
defaultSeatedBookingMetadata
|
||||
);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
return attendeeParsed;
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
});
|
||||
|
||||
// TODO: Uncomment this once CI is ready to run proper Office365 tests
|
||||
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
|
||||
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar OAuth with valid access token `, async () => {
|
||||
// const response = await request(app.getHttpServer())
|
||||
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
|
||||
// .set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
@@ -123,7 +123,7 @@ describe("Platform Calendars Endpoints", () => {
|
||||
// expect(data.authUrl).toBeDefined();
|
||||
// });
|
||||
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
|
||||
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
|
||||
@@ -4,13 +4,14 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { Logger, NotFoundException } from "@nestjs/common";
|
||||
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
|
||||
@@ -78,7 +79,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
|
||||
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
throw new BadRequestException("Invalid google OAuth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
@@ -134,8 +135,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
|
||||
oAuth2Client.setCredentials(key);
|
||||
|
||||
const calendar = google.calendar({
|
||||
version: "v3",
|
||||
const calendar = new calendar_v3.Calendar({
|
||||
auth: oAuth2Client,
|
||||
});
|
||||
|
||||
|
||||
@@ -78,4 +78,11 @@ export class EventTypesRepository_2024_04_15 {
|
||||
async deleteEventType(eventTypeId: number) {
|
||||
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
|
||||
}
|
||||
|
||||
async getEventTypeWithDuration(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
select: { length: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+187
-6
@@ -10,10 +10,12 @@ import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User, Schedule } from "@prisma/client";
|
||||
import { PlatformOAuthClient, Team, User, Schedule, EventType } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
@@ -75,18 +77,22 @@ describe("Event types Endpoints", () => {
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
const userEmail = "event-types-test-e2e@api.com";
|
||||
const falseTestEmail = "false-event-types@api.com";
|
||||
const name = "bob-the-builder";
|
||||
const username = name;
|
||||
let eventType: EventTypeOutput_2024_06_14;
|
||||
let user: User;
|
||||
let orgUser: User;
|
||||
let falseTestUser: User;
|
||||
let firstSchedule: Schedule;
|
||||
let secondSchedule: Schedule;
|
||||
let falseTestSchedule: Schedule;
|
||||
|
||||
let orgUserEventType1: EventType;
|
||||
let orgUserEventType2: EventType;
|
||||
let orgUserEventType3: EventType;
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
@@ -109,8 +115,12 @@ describe("Event types Endpoints", () => {
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({ name: "organization" });
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: "organization",
|
||||
slug: "event-type-2024-06-14-org-slug",
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
@@ -118,6 +128,49 @@ describe("Event types Endpoints", () => {
|
||||
username,
|
||||
});
|
||||
|
||||
orgUser = await userRepositoryFixture.create({
|
||||
email: "event-types-2024-06-14-org-user@example.com",
|
||||
name: "event-types-2024-06-14-org-user",
|
||||
username: "event-types-2024-06-14-org-user",
|
||||
});
|
||||
|
||||
profileRepositoryFixture.create({
|
||||
uid: `usr-${orgUser.id}`,
|
||||
username: orgUser.username as string,
|
||||
organization: {
|
||||
connect: {
|
||||
id: organization.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: orgUser.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
orgUserEventType1 = await eventTypesRepositoryFixture.create(
|
||||
{ title: "orgUserEventType1", slug: "org-event-type-1", length: 60, locations: [] },
|
||||
orgUser.id
|
||||
);
|
||||
|
||||
orgUserEventType2 = await eventTypesRepositoryFixture.create(
|
||||
{ title: "orgUserEventType2", slug: "org-event-type-2", length: 60, locations: [] },
|
||||
orgUser.id
|
||||
);
|
||||
|
||||
orgUserEventType3 = await eventTypesRepositoryFixture.create(
|
||||
{ title: "orgUserEventType3", slug: "org-event-type-3", length: 60, locations: [] },
|
||||
orgUser.id
|
||||
);
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: orgUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
falseTestUser = await userRepositoryFixture.create({
|
||||
email: falseTestEmail,
|
||||
name: "false-test",
|
||||
@@ -342,7 +395,7 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it(`/GET/even-types by username`, async () => {
|
||||
it(`/GET/event-types by username`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${username}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
@@ -386,6 +439,61 @@ describe("Event types Endpoints", () => {
|
||||
expect(fetchedEventType.color).toEqual(eventType.color);
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and orgSlug`, async () => {
|
||||
console.log(organization);
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(3);
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and orgSlug and eventSlug`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(
|
||||
`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}&eventSlug=${orgUserEventType1.slug}`
|
||||
)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(1);
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/event-types by username and orgId`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types?username=${orgUser.username}&orgId=${organization.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
|
||||
.set("Authorization", `Bearer whatever`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
|
||||
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data?.length).toEqual(3);
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
|
||||
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class 2",
|
||||
@@ -919,6 +1027,12 @@ describe("Event types Endpoints", () => {
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
|
||||
try {
|
||||
await userRepositoryFixture.delete(orgUser.id);
|
||||
} catch (e) {
|
||||
// User might have been deleted by the test
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
@@ -1321,6 +1435,73 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should return event type with default bookingFields if they are not defined", async () => {
|
||||
const eventTypeInput = {
|
||||
title: "undefined booking fields",
|
||||
description: "undefined booking fields",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "undefined-booking-fields",
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
};
|
||||
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(fetchedEventType.bookingFields).toEqual([
|
||||
{
|
||||
isDefault: true,
|
||||
type: "name",
|
||||
slug: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "email",
|
||||
slug: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "radioInput",
|
||||
slug: "location",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "text",
|
||||
slug: "title",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "textarea",
|
||||
slug: "notes",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "multiemail",
|
||||
slug: "guests",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "textarea",
|
||||
slug: "rescheduleReason",
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ export class EventTypeResponseTransformPipe implements PipeTransform {
|
||||
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
|
||||
return plainToClass(
|
||||
EventTypeOutput_2024_06_14,
|
||||
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
|
||||
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType, false),
|
||||
{ strategy: "exposeAll" }
|
||||
);
|
||||
}
|
||||
|
||||
+15
-11
@@ -74,8 +74,13 @@ export class EventTypesService_2024_06_14 {
|
||||
await this.checkUserOwnsSchedule(userId, body.scheduleId);
|
||||
}
|
||||
|
||||
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
async getEventTypeByUsernameAndSlug(
|
||||
username: string,
|
||||
eventTypeSlug: string,
|
||||
orgSlug?: string,
|
||||
orgId?: number
|
||||
) {
|
||||
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
@@ -92,8 +97,8 @@ export class EventTypesService_2024_06_14 {
|
||||
};
|
||||
}
|
||||
|
||||
async getEventTypesByUsername(username: string) {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) {
|
||||
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
|
||||
if (!user) {
|
||||
return [];
|
||||
}
|
||||
@@ -152,27 +157,26 @@ export class EventTypesService_2024_06_14 {
|
||||
}
|
||||
|
||||
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
|
||||
const { username, eventSlug, usernames } = queryParams;
|
||||
|
||||
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
|
||||
if (username && eventSlug) {
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug);
|
||||
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId);
|
||||
return eventType ? [eventType] : [];
|
||||
}
|
||||
|
||||
if (username) {
|
||||
return await this.getEventTypesByUsername(username);
|
||||
return await this.getEventTypesByUsername(username, orgSlug, orgId);
|
||||
}
|
||||
|
||||
if (usernames) {
|
||||
const dynamicEventType = await this.getDynamicEventType(usernames);
|
||||
const dynamicEventType = await this.getDynamicEventType(usernames, orgSlug, orgId);
|
||||
return [dynamicEventType];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async getDynamicEventType(usernames: string[]) {
|
||||
const users = await this.usersService.getByUsernames(usernames);
|
||||
async getDynamicEventType(usernames: string[], orgSlug?: string, orgId?: number) {
|
||||
const users = await this.usersService.getByUsernames(usernames, orgSlug, orgId);
|
||||
const usersFiltered: UserWithProfile[] = [];
|
||||
for (const user of users) {
|
||||
if (user) {
|
||||
|
||||
+19
-2
@@ -21,6 +21,7 @@ import {
|
||||
transformSeatsInternalToApi,
|
||||
InternalLocation,
|
||||
BookingFieldSchema,
|
||||
getBookingFieldsWithSystemFields,
|
||||
} from "@calcom/platform-libraries";
|
||||
import {
|
||||
TransformFutureBookingsLimitSchema_2024_06_14,
|
||||
@@ -88,7 +89,11 @@ type Input = Pick<
|
||||
|
||||
@Injectable()
|
||||
export class OutputEventTypesService_2024_06_14 {
|
||||
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
|
||||
getResponseEventType(
|
||||
ownerId: number,
|
||||
databaseEventType: Input,
|
||||
isOrgTeamEvent: boolean
|
||||
): EventTypeOutput_2024_06_14 {
|
||||
const {
|
||||
id,
|
||||
length,
|
||||
@@ -122,7 +127,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
const customName = databaseEventType?.eventName ?? undefined;
|
||||
const bookingFields = databaseEventType.bookingFields
|
||||
? this.transformBookingFields(databaseEventType.bookingFields)
|
||||
: [];
|
||||
: this.getDefaultBookingFields(isOrgTeamEvent);
|
||||
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
|
||||
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
|
||||
const users = this.transformUsers(databaseEventType.users || []);
|
||||
@@ -240,6 +245,18 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
|
||||
}
|
||||
|
||||
getDefaultBookingFields(isOrgTeamEvent: boolean) {
|
||||
const defaultBookingFields = getBookingFieldsWithSystemFields({
|
||||
disableGuests: false,
|
||||
bookingFields: null,
|
||||
customInputs: [],
|
||||
metadata: null,
|
||||
workflows: [],
|
||||
isOrgTeamEvent,
|
||||
});
|
||||
return this.transformBookingFields(defaultBookingFields);
|
||||
}
|
||||
|
||||
transformRecurringEvent(recurringEvent: any) {
|
||||
if (!recurringEvent) return null;
|
||||
const recurringEventParsed = parseRecurringEvent(recurringEvent);
|
||||
|
||||
@@ -95,7 +95,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
.expect(401);
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
|
||||
it(`/GET/gcal/oauth/auth-url: it should auth-url to google OAuth with valid access token `, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/v2/gcal/oauth/auth-url`)
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
@@ -105,7 +105,7 @@ describe("Platform Gcal Endpoints", () => {
|
||||
expect(data.authUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
|
||||
it(`/GET/gcal/oauth/save: without OAuth code`, async () => {
|
||||
await request(app.getHttpServer())
|
||||
.get(
|
||||
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
|
||||
|
||||
@@ -27,10 +27,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
import { google } from "googleapis";
|
||||
import { z } from "zod";
|
||||
|
||||
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@@ -103,7 +100,7 @@ export class GcalController {
|
||||
}
|
||||
|
||||
if (gcalCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid google oauth credentials.");
|
||||
throw new BadRequestException("Invalid google OAuth credentials.");
|
||||
}
|
||||
|
||||
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { SlotsModule } from "@/modules/slots/slots.module";
|
||||
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
|
||||
CalendarsModule,
|
||||
BookingsModule_2024_04_15,
|
||||
BookingsModule_2024_08_13,
|
||||
TeamsMembershipsModule,
|
||||
SlotsModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const defaultBookingResponses = {
|
||||
name: "unknown",
|
||||
email: "unknown",
|
||||
guests: [],
|
||||
rescheduleReason: "unknown",
|
||||
};
|
||||
|
||||
export const defaultBookingMetadata = { videoCallUrl: "unknown" };
|
||||
|
||||
export const defaultSeatedBookingData = { responses: { name: "unknown", email: "unknown" } };
|
||||
|
||||
export const defaultSeatedBookingMetadata = {};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { ZodSchema } from "zod";
|
||||
|
||||
const logger = new Logger("safeParse");
|
||||
|
||||
export function safeParse<T>(schema: ZodSchema<T>, value: unknown, defaultValue: T): T {
|
||||
const result = schema.safeParse(value);
|
||||
if (result.success) {
|
||||
return result.data;
|
||||
} else {
|
||||
const errorStack = new Error().stack;
|
||||
|
||||
logger.error(
|
||||
`Zod parsing failed.\n` +
|
||||
`1. Schema: ${schema.description || "UnnamedSchema"}\n` +
|
||||
`2. Input: ${JSON.stringify(value, null, 2)}\n` +
|
||||
`3. Zod Error: ${result.error}\n` +
|
||||
`4. Call Stack: ${errorStack}`
|
||||
);
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,15 @@ export class AppsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAppCredentials(credentialIdsToDelete: number[], userId: number) {
|
||||
return this.dbWrite.prisma.credential.deleteMany({
|
||||
where: {
|
||||
id: { in: credentialIdsToDelete },
|
||||
userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
@@ -32,4 +41,28 @@ export class AppsRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAppCredential({
|
||||
type,
|
||||
appId,
|
||||
userId,
|
||||
teamId,
|
||||
}: {
|
||||
type: string;
|
||||
appId: string;
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
}) {
|
||||
return this.dbWrite.prisma.credential.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
type,
|
||||
userId,
|
||||
teamId,
|
||||
appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { google } from "googleapis";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import { z } from "zod";
|
||||
|
||||
@Injectable()
|
||||
@@ -21,7 +21,7 @@ export class GCalService {
|
||||
|
||||
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
|
||||
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
|
||||
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
|
||||
return oAuth2Client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 { 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";
|
||||
@@ -15,6 +16,7 @@ import { Module } from "@nestjs/common";
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
ConferencingAtomsService,
|
||||
MembershipsRepository,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import {
|
||||
BulkUpdateEventTypeToDefaultLocationDto,
|
||||
EventTypesAppInput,
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
@@ -18,8 +22,9 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
|
||||
import { ConnectedApps } from "@calcom/platform-libraries";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
@@ -36,7 +41,10 @@ these endpoints should not be recommended for use by third party and are exclude
|
||||
@DocsTags("Atoms - endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsController {
|
||||
constructor(private readonly eventTypesService: EventTypesAtomService) {}
|
||||
constructor(
|
||||
private readonly eventTypesService: EventTypesAtomService,
|
||||
private readonly conferencingService: ConferencingAtomsService
|
||||
) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@@ -52,6 +60,17 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("event-types-app/:appSlug")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -84,6 +103,19 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async bulkUpdateAtomEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -114,4 +146,13 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: conferencingApps };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsNumber, IsOptional } from "class-validator";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator";
|
||||
|
||||
export class EventTypesAppInput {
|
||||
@Transform(({ value }: { value: string }) => value && parseInt(value))
|
||||
@@ -7,3 +7,10 @@ export class EventTypesAppInput {
|
||||
@IsOptional()
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export class BulkUpdateEventTypeToDefaultLocationDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsInt({ each: true })
|
||||
eventTypeIds!: number[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getConnectedApps, ConnectedApps } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class ConferencingAtomsService {
|
||||
private logger = new Logger("ConferencingAtomService");
|
||||
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
getAppFromSlug,
|
||||
MembershipRole,
|
||||
EventTypeMetaDataSchema,
|
||||
getClientSecretFromPayment,
|
||||
getBulkEventTypes,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
} from "@calcom/platform-libraries";
|
||||
import type {
|
||||
App,
|
||||
@@ -29,8 +32,7 @@ import type {
|
||||
TDependencyData,
|
||||
CredentialPayload,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
import { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
type EnabledAppType = App & {
|
||||
credential: CredentialDataWithTeamName;
|
||||
@@ -81,6 +83,10 @@ export class EventTypesAtomService {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return getBulkEventTypes(userId);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
eventTypeId: number,
|
||||
body: TUpdateEventTypeInputSchema,
|
||||
@@ -317,4 +323,12 @@ export class EventTypesAtomService {
|
||||
profile,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) {
|
||||
return bulkUpdateEventsToDefaultLocation({
|
||||
eventTypeIds,
|
||||
user,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ describe("PermissionsGuard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when oauth id is provided", () => {
|
||||
describe("when OAuth id is provided", () => {
|
||||
it("should return true for valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
|
||||
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 { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
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 { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
GoogleMeetService,
|
||||
CredentialsRepository,
|
||||
UsersRepository,
|
||||
TokensRepository,
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
AppsRepository,
|
||||
],
|
||||
exports: [],
|
||||
controllers: [ConferencingController],
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
ConferencingAppsOauthUrlOutputDto,
|
||||
GetConferencingAppsOauthUrlResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
|
||||
import {
|
||||
ConferencingAppsOutputResponseDto,
|
||||
ConferencingAppOutputResponseDto,
|
||||
ConferencingAppsOutputDto,
|
||||
DisconnectConferencingAppOutputResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
|
||||
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 { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
@@ -20,11 +31,24 @@ import {
|
||||
Param,
|
||||
BadRequestException,
|
||||
Delete,
|
||||
Headers,
|
||||
Redirect,
|
||||
UnauthorizedException,
|
||||
Req,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
|
||||
import { CONFERENCING_APPS, GOOGLE_MEET, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: number;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/conferencing",
|
||||
@@ -35,8 +59,11 @@ export class ConferencingController {
|
||||
private readonly logger = new Logger("Platform Gcal Provider");
|
||||
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly googleMeetService: GoogleMeetService
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -56,16 +83,105 @@ export class ConferencingController {
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
CONFERENCING_APPS.join(", ")
|
||||
[GOOGLE_MEET].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("app") app: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
let credential;
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
returnTo: returnTo ?? origin,
|
||||
onErrorReturnTo: onErrorReturnTo ?? origin,
|
||||
fromApp: false,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
|
||||
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(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/callback")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "conferencing apps oauths callback" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Param("app") app: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined
|
||||
): Promise<{ url: string }> {
|
||||
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
|
||||
try {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
|
||||
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(", ")
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
url: decodedCallbackState.onErrorReturnTo ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "List your conferencing applications" })
|
||||
async listConferencingApps(@GetUser("id") userId: number): Promise<ConferencingAppsOutputResponseDto> {
|
||||
async listInstalledConferencingApps(
|
||||
@GetUser("id") userId: number
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
|
||||
|
||||
const data = conferencingApps.map((conferencingApps) =>
|
||||
@@ -83,18 +199,17 @@ export class ConferencingController {
|
||||
@GetUser("id") userId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<SetDefaultConferencingAppOutputResponseDto> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
await this.googleMeetService.setDefault(userId);
|
||||
await this.conferencingService.setDefaultConferencingApp(userId, app);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
CONFERENCING_APPS.join(", ")
|
||||
);
|
||||
}
|
||||
@Get("/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Get your default conferencing application" })
|
||||
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
|
||||
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
|
||||
}
|
||||
|
||||
@Delete("/:app/disconnect")
|
||||
@@ -102,20 +217,10 @@ export class ConferencingController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiOperation({ summary: "Disconnect your conferencing application" })
|
||||
async disconnect(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.disconnectGoogleMeetApp(userId);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
CONFERENCING_APPS.join(", ")
|
||||
);
|
||||
}
|
||||
): Promise<DisconnectConferencingAppOutputResponseDto> {
|
||||
await this.conferencingService.disconnectConferencingApp(user, app);
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class ConferencingAppsOauthUrlOutputDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly url!: string;
|
||||
}
|
||||
|
||||
export class GetConferencingAppsOauthUrlResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => ConferencingAppsOauthUrlOutputDto)
|
||||
data!: ConferencingAppsOauthUrlOutputDto;
|
||||
}
|
||||
@@ -48,3 +48,9 @@ export class ConferencingAppOutputResponseDto {
|
||||
@Type(() => ConferencingAppsOutputDto)
|
||||
data!: ConferencingAppsOutputDto;
|
||||
}
|
||||
|
||||
export class DisconnectConferencingAppOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DefaultConferencingAppsOutputDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
readonly appSlug?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
readonly appLink?: string;
|
||||
}
|
||||
|
||||
export class GetDefaultConferencingAppOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@IsOptional()
|
||||
@Type(() => DefaultConferencingAppsOutputDto)
|
||||
data?: DefaultConferencingAppsOutputDto;
|
||||
}
|
||||
@@ -22,4 +22,10 @@ export class ConferencingRepository {
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
});
|
||||
}
|
||||
|
||||
async findConferencingApp(userId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, appId: app },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,61 @@
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
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 { userMetadata, handleDeleteCredential } from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class ConferencingService {
|
||||
private logger = new Logger("ConferencingService");
|
||||
|
||||
constructor(private readonly conferencingRepository: ConferencingRepository) {}
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async getConferencingApps(userId: number) {
|
||||
return this.conferencingRepository.findConferencingApps(userId);
|
||||
}
|
||||
|
||||
async getUserDefaultConferencingApp(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(userId: number, app: string) {
|
||||
if (!CONFERENCING_APPS.includes(app)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
|
||||
}
|
||||
const credential = await this.conferencingRepository.findConferencingApp(userId, app);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(`${app} not connected.`);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
async disconnectConferencingApp(user: UserWithProfile, app: string) {
|
||||
const credential = await this.checkAppIsValidAndConnected(user.id, app);
|
||||
return handleDeleteCredential({
|
||||
userId: user.id,
|
||||
userMetadata: user?.metadata,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp(userId: number, app: string) {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (app !== CAL_VIDEO) {
|
||||
await this.checkAppIsValidAndConnected(userId, app);
|
||||
}
|
||||
const user = await this.usersRepository.setDefaultConferencingApp(userId, app);
|
||||
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
|
||||
if (metadata?.defaultConferencingApp?.appSlug !== app) {
|
||||
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,29 +43,4 @@ export class GoogleMeetService {
|
||||
|
||||
return googleMeetCredential;
|
||||
}
|
||||
|
||||
async disconnectGoogleMeetApp(userId: number) {
|
||||
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
|
||||
if (!googleMeet) {
|
||||
throw new BadRequestException("Google Meet is not connected.");
|
||||
}
|
||||
|
||||
const googleMeetCredential = await this.credentialsRepository.deleteUserCredentialById(
|
||||
userId,
|
||||
googleMeet.id
|
||||
);
|
||||
|
||||
return googleMeetCredential;
|
||||
}
|
||||
|
||||
async setDefault(userId: number) {
|
||||
const user = await this.usersRepository.setDefaultConferencingApp(userId, GOOGLE_MEET);
|
||||
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
|
||||
|
||||
if (metadata?.defaultConferencingApp?.appSlug !== GOOGLE_MEET) {
|
||||
throw new InternalServerErrorException("Could not set Google Meet as default conferencing app");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
const zoomAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class Office365VideoService {
|
||||
private logger = new Logger("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) {}
|
||||
|
||||
async getOffice365AppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
|
||||
|
||||
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException("Office365 app not found");
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException("Office365 app not found");
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async generateOffice365AuthUrl(state: string) {
|
||||
const { client_id } = await this.getOffice365AppKeys();
|
||||
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
scope: this.scopes.join(" "),
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
|
||||
const { client_id, client_secret } = await this.getOffice365AppKeys();
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
Object.keys(payload)
|
||||
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
|
||||
.join("&");
|
||||
|
||||
const body = toUrlEncoded({
|
||||
client_id,
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
scope: this.scopes.join(" "),
|
||||
redirect_uri: this.redirectUri,
|
||||
client_secret,
|
||||
});
|
||||
|
||||
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new BadRequestException(responseBody.error);
|
||||
}
|
||||
|
||||
const whoami = await fetch("https://graph.microsoft.com/v1.0/me", {
|
||||
headers: { Authorization: `Bearer ${responseBody.access_token}` },
|
||||
});
|
||||
|
||||
const graphUser = await whoami.json();
|
||||
|
||||
// In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
|
||||
responseBody.email = graphUser.mail ?? graphUser.userPrincipalName;
|
||||
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 credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZOOM, ZOOM_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
const zoomAppKeysSchema = z.object({
|
||||
client_id: z.string(),
|
||||
client_secret: z.string(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
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) {}
|
||||
|
||||
async getZoomAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(ZOOM);
|
||||
|
||||
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException("Zoom app not found");
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException("Zoom app not found");
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async generateZoomAuthUrl(state: string) {
|
||||
const { client_id } = await this.getZoomAppKeys();
|
||||
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
const url = `https://zoom.us/oauth/authorize?${query}`;
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: 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")}`;
|
||||
const result = await fetch(
|
||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: authHeader,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (result.status !== 200) {
|
||||
let errorMessage = "Something is wrong with Zoom API";
|
||||
try {
|
||||
const responseBody = await result.json();
|
||||
errorMessage = responseBody.error;
|
||||
} catch (e) {
|
||||
errorMessage = await result.clone().text();
|
||||
}
|
||||
throw new BadRequestException(errorMessage);
|
||||
}
|
||||
|
||||
const responseBody = await result.json();
|
||||
|
||||
if (responseBody.error) {
|
||||
throw new BadRequestException(responseBody.error);
|
||||
}
|
||||
|
||||
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
|
||||
delete responseBody.expires_in;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
|
||||
type: ZOOM_TYPE,
|
||||
userId,
|
||||
appId: ZOOM,
|
||||
});
|
||||
|
||||
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
}
|
||||
+5
@@ -159,6 +159,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
email: userEmail,
|
||||
timeZone: "incorrect-time-zone",
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
@@ -191,6 +192,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -211,6 +213,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
|
||||
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();
|
||||
|
||||
@@ -265,6 +268,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
@@ -535,6 +539,7 @@ describe("OAuth Client Users Endpoints", () => {
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Alice Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
|
||||
+6
-1
@@ -162,7 +162,11 @@ export class OAuthClientUsersController {
|
||||
|
||||
@Post("/:userId/force-refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Force refresh tokens" })
|
||||
@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.`,
|
||||
})
|
||||
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
|
||||
async forceRefresh(
|
||||
@Param("userId") userId: number,
|
||||
@@ -209,6 +213,7 @@ export class OAuthClientUsersController {
|
||||
timeFormat: user.timeFormat,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
locale: user.locale as Locales,
|
||||
avatarUrl: user.avatarUrl,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -1,7 +1,7 @@
|
||||
import { Locales } from "@/lib/enums/locales";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsEnum, IsOptional } from "class-validator";
|
||||
import { IsEnum, IsOptional, IsUrl } from "class-validator";
|
||||
|
||||
export class ManagedUserOutput {
|
||||
@ApiProperty({ example: 1 })
|
||||
@@ -36,4 +36,14 @@ export class ManagedUserOutput {
|
||||
@IsOptional()
|
||||
@ApiProperty({ example: Locales.EN, enum: Locales })
|
||||
locale?: Locales;
|
||||
|
||||
@IsUrl()
|
||||
@IsOptional()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
description: `URL of the user's avatar image`,
|
||||
required: false,
|
||||
})
|
||||
avatarUrl?: string | null;
|
||||
}
|
||||
|
||||
+5
-5
@@ -24,10 +24,10 @@ import { withNextAuth } from "test/utils/withNextAuth";
|
||||
|
||||
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
|
||||
|
||||
describe("EventTypes WebhooksController (e2e)", () => {
|
||||
describe("OAuth client WebhooksController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
const userEmail = "event-types-webhook-controller-e2e@api.com";
|
||||
const otherUserEmail = "other-event-types-webhook-controller-e2e@api.com";
|
||||
const userEmail = "oauth-client-webhook-controller-e2e@api.com";
|
||||
const otherUserEmail = "other-oauth-client-webhook-controller-e2e@api.com";
|
||||
let user: UserWithProfile;
|
||||
let otherUser: UserWithProfile;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
@@ -244,7 +244,7 @@ describe("EventTypes WebhooksController (e2e)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
it("/webhooks (GET) should fail to get webhooks of oauth client that doesn't belong to you", () => {
|
||||
it("/webhooks (GET) should fail to get webhooks of OAuth client that doesn't belong to you", () => {
|
||||
return request(app.getHttpServer()).get(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks`).expect(403);
|
||||
});
|
||||
|
||||
@@ -267,7 +267,7 @@ describe("EventTypes WebhooksController (e2e)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an oauth client that doesn't belong to you", () => {
|
||||
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an OAuth client that doesn't belong to you", () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks/${otherOAuthClientWebhook.id}`)
|
||||
.expect(403);
|
||||
|
||||
+8
-3
@@ -30,6 +30,7 @@ import {
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
ApiOperation,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse } from "express";
|
||||
|
||||
@@ -39,7 +40,6 @@ import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("OAuth")
|
||||
export class OAuthFlowController {
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
@@ -115,13 +115,18 @@ export class OAuthFlowController {
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@DocsTags("Managed users")
|
||||
@DocsTags("Platform / Managed Users")
|
||||
@DocsHeader({
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "OAuth client secret key.",
|
||||
required: true,
|
||||
})
|
||||
async refreshAccessToken(
|
||||
@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.`,
|
||||
})
|
||||
async refreshTokens(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
@Body() body: RefreshTokenInput
|
||||
|
||||
@@ -66,9 +66,11 @@ export class OAuthClientUsersService {
|
||||
const updatedUser = await this.userRepository.update(user.id, {
|
||||
name: body.name,
|
||||
locale: body.locale,
|
||||
avatarUrl: body.avatarUrl,
|
||||
});
|
||||
user.locale = updatedUser.locale;
|
||||
user.name = updatedUser.name;
|
||||
user.avatarUrl = updatedUser.avatarUrl;
|
||||
}
|
||||
|
||||
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
|
||||
|
||||
+82
-2
@@ -6,7 +6,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 { SchedulingType, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
@@ -235,6 +235,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
it("should create a collective team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
successRedirectUrl: "https://masterchief.com/argentina/flan/video/1234",
|
||||
title: "Coding consultation collective",
|
||||
slug: "coding-consultation collective",
|
||||
description: "Our team will review your codebase.",
|
||||
@@ -334,7 +335,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
|
||||
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
|
||||
expect(data.color).toEqual(body.color);
|
||||
|
||||
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
|
||||
collectiveEventType = responseBody.data;
|
||||
});
|
||||
});
|
||||
@@ -496,6 +497,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
hosts: newHosts,
|
||||
successRedirectUrl: "https://new-url-success.com",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -507,6 +509,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
const eventType = responseBody.data;
|
||||
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
|
||||
expect(eventType.title).toEqual(collectiveEventType.title);
|
||||
expect(eventType.hosts.length).toEqual(1);
|
||||
evaluateHost(eventType.hosts[0], newHosts[0]);
|
||||
@@ -526,6 +529,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
title: newTitle,
|
||||
hosts: newHosts,
|
||||
successRedirectUrl: "https://new-url-success-managed.com",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -569,6 +573,7 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
expect(responseTeammate1Event?.title).toEqual(newTitle);
|
||||
|
||||
managedEventType = responseBody.data[0];
|
||||
expect(managedEventType.successRedirectUrl).toEqual("https://new-url-success-managed.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -649,6 +654,81 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
it("should return event type with default bookingFields if they are not defined", async () => {
|
||||
const eventTypeInput = {
|
||||
title: "unknown field event type two",
|
||||
description: "unknown field event type description two",
|
||||
length: 40,
|
||||
hidden: false,
|
||||
slug: "unknown-field-type-two",
|
||||
locations: [],
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
};
|
||||
const eventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
...eventTypeInput,
|
||||
team: { connect: { id: team.id } },
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}`)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
|
||||
const fetchedEventType = responseBody.data;
|
||||
|
||||
expect(fetchedEventType.bookingFields).toEqual([
|
||||
{
|
||||
isDefault: true,
|
||||
type: "name",
|
||||
slug: "name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "email",
|
||||
slug: "email",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
required: false,
|
||||
slug: "attendeePhoneNumber",
|
||||
type: "phone",
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "radioInput",
|
||||
slug: "location",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "text",
|
||||
slug: "title",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "textarea",
|
||||
slug: "notes",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "multiemail",
|
||||
slug: "guests",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
isDefault: true,
|
||||
type: "textarea",
|
||||
slug: "rescheduleReason",
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function evaluateHost(expected: Host, received: Host | undefined) {
|
||||
expect(expected.userId).toEqual(received?.userId);
|
||||
expect(expected.mandatory).toEqual(received?.mandatory);
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ export class OutputTeamEventTypesResponsePipe implements PipeTransform {
|
||||
private async transformEventType(item: DatabaseTeamEventType): Promise<TeamEventTypeOutput_2024_06_14> {
|
||||
return plainToClass(
|
||||
TeamEventTypeOutput_2024_06_14,
|
||||
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item),
|
||||
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item, true),
|
||||
{ strategy: "exposeAll" }
|
||||
);
|
||||
}
|
||||
|
||||
+14
@@ -137,6 +137,8 @@ describe("Organizations Team Endpoints", () => {
|
||||
.post(`/v2/organizations/${org.id}/teams`)
|
||||
.send({
|
||||
name: "Team created via API",
|
||||
slug: "team-created-via-api",
|
||||
bio: "This is our test team created via API",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
@@ -144,6 +146,8 @@ describe("Organizations Team Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi = responseBody.data;
|
||||
expect(teamCreatedViaApi.name).toEqual("Team created via API");
|
||||
expect(teamCreatedViaApi.slug).toEqual("team-created-via-api");
|
||||
expect(teamCreatedViaApi.bio).toEqual("This is our test team created via API");
|
||||
expect(teamCreatedViaApi.parentId).toEqual(org.id);
|
||||
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
|
||||
user.id,
|
||||
@@ -171,6 +175,9 @@ describe("Organizations Team Endpoints", () => {
|
||||
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
|
||||
.send({
|
||||
name: "Team created via API Updated",
|
||||
weekStart: "Monday",
|
||||
logoUrl: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
|
||||
bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
|
||||
} satisfies CreateOrgTeamDto)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
@@ -178,6 +185,13 @@ describe("Organizations Team Endpoints", () => {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
teamCreatedViaApi = responseBody.data;
|
||||
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
|
||||
expect(teamCreatedViaApi.weekStart).toEqual("Monday");
|
||||
expect(teamCreatedViaApi.logoUrl).toEqual(
|
||||
"https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png"
|
||||
);
|
||||
expect(teamCreatedViaApi.bannerUrl).toEqual(
|
||||
"https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png"
|
||||
);
|
||||
expect(teamCreatedViaApi.parentId).toEqual(org.id);
|
||||
});
|
||||
});
|
||||
|
||||
+27
-7
@@ -12,10 +12,12 @@ import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
|
||||
import { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
|
||||
import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input";
|
||||
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
|
||||
import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import {
|
||||
GetOrganizationUsersResponseDTO,
|
||||
GetOrgUsersWithProfileOutput,
|
||||
} from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
|
||||
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -55,7 +57,7 @@ export class OrganizationsUsersController {
|
||||
async getOrganizationsUsers(
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Query() query: GetOrganizationsUsersInput
|
||||
): Promise<GetOrganizationUsersOutput> {
|
||||
): Promise<GetOrganizationUsersResponseDTO> {
|
||||
const users = await this.organizationsUsersService.getUsers(
|
||||
orgId,
|
||||
query.emails,
|
||||
@@ -65,7 +67,13 @@ export class OrganizationsUsersController {
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
|
||||
data: users.map((user) =>
|
||||
plainToInstance(
|
||||
GetOrgUsersWithProfileOutput,
|
||||
{ ...user, profile: user?.profiles?.[0] ?? {} },
|
||||
{ strategy: "excludeAll" }
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,7 +94,11 @@ export class OrganizationsUsersController {
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
data: plainToInstance(
|
||||
GetOrgUsersWithProfileOutput,
|
||||
{ ...user, profile: user?.profiles?.[0] ?? {} },
|
||||
{ strategy: "excludeAll" }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +116,11 @@ export class OrganizationsUsersController {
|
||||
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
data: plainToInstance(
|
||||
GetOrgUsersWithProfileOutput,
|
||||
{ ...user, profile: user?.profiles?.[0] ?? {} },
|
||||
{ strategy: "excludeAll" }
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +136,11 @@ export class OrganizationsUsersController {
|
||||
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
|
||||
data: plainToInstance(
|
||||
GetOrgUsersWithProfileOutput,
|
||||
{ ...user, profile: user?.profiles?.[0] ?? {} },
|
||||
{ strategy: "excludeAll" }
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+13
-3
@@ -1,6 +1,7 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/outputs/get-organization-users.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -233,10 +234,18 @@ describe("Organizations Users Endpoints", () => {
|
||||
it("should get all org users", async () => {
|
||||
const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
|
||||
|
||||
const userData = body.data;
|
||||
const userData = body.data as GetOrgUsersWithProfileOutput[];
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(4);
|
||||
console.log(
|
||||
"profiles",
|
||||
{ userData },
|
||||
userData.map((u) => u.profile)
|
||||
);
|
||||
expect(userData.find((u) => u.profile.username === "member1@org.com")).toBeDefined();
|
||||
expect(userData.find((u) => u.profile.username === "member2@org.com")).toBeDefined();
|
||||
expect(userData.find((u) => u.profile.username === "member3@org.com")).toBeDefined();
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
|
||||
});
|
||||
@@ -250,12 +259,13 @@ describe("Organizations Users Endpoints", () => {
|
||||
.set("Content-Type", "application/json")
|
||||
.set("Accept", "application/json");
|
||||
|
||||
const userData = body.data;
|
||||
const userData = body.data as GetOrgUsersWithProfileOutput[];
|
||||
|
||||
expect(body.status).toBe(SUCCESS_STATUS);
|
||||
expect(userData.length).toBe(1);
|
||||
|
||||
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
|
||||
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
|
||||
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
|
||||
});
|
||||
|
||||
it("should get users within the specified emails array", async () => {
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
export class CreateOrgTeamDto {
|
||||
@IsString()
|
||||
@Length(1)
|
||||
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
|
||||
readonly name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
|
||||
description: `URL of the teams logo image`,
|
||||
required: false,
|
||||
})
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -59,6 +68,12 @@ export class CreateOrgTeamDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
|
||||
description: `URL of the teams banner image which is shown on booker`,
|
||||
required: false,
|
||||
})
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -67,10 +82,23 @@ export class CreateOrgTeamDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "America/New_York",
|
||||
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
|
||||
required: false,
|
||||
default: "Europe/London",
|
||||
})
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "Monday",
|
||||
required: false,
|
||||
default: "Sunday",
|
||||
})
|
||||
readonly weekStart?: string = "Sunday";
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import { ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
|
||||
|
||||
export class UpdateOrgTeamDto {
|
||||
@IsString()
|
||||
@Length(1)
|
||||
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam", required: false })
|
||||
readonly name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
|
||||
readonly slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
|
||||
description: `URL of the teams logo image`,
|
||||
required: false,
|
||||
})
|
||||
readonly logoUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -59,6 +68,12 @@ export class UpdateOrgTeamDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsUrl()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
|
||||
description: `URL of the teams banner image which is shown on booker`,
|
||||
required: false,
|
||||
})
|
||||
readonly bannerUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@@ -67,10 +82,23 @@ export class UpdateOrgTeamDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "America/New_York",
|
||||
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
|
||||
required: false,
|
||||
default: "Europe/London",
|
||||
})
|
||||
readonly timeZone?: string = "Europe/London";
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
example: "Monday",
|
||||
required: false,
|
||||
default: "Sunday",
|
||||
})
|
||||
readonly weekStart?: string = "Sunday";
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,20 +1,64 @@
|
||||
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEnum } from "class-validator";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetOrganizationUsersOutput {
|
||||
export class ProfileOutput {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({ type: Number, required: true, description: "The ID of the profile of user", example: 1 })
|
||||
id!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: Number,
|
||||
required: true,
|
||||
description: "The ID of the organization of user",
|
||||
example: 1,
|
||||
})
|
||||
organizationId!: number;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
@ApiProperty({ type: Number, required: true, description: "The IDof the user", example: 1 })
|
||||
userId!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
nullable: true,
|
||||
required: false,
|
||||
description: "The username of the user within the organization context",
|
||||
example: "john_doe",
|
||||
})
|
||||
username!: string;
|
||||
}
|
||||
export class GetOrgUsersWithProfileOutput extends GetUserOutput {
|
||||
@ApiProperty({
|
||||
description: "organization user profile, contains user data within the organizaton context",
|
||||
})
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@IsArray()
|
||||
@Type(() => ProfileOutput)
|
||||
profile!: ProfileOutput;
|
||||
}
|
||||
|
||||
export class GetOrganizationUsersResponseDTO {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
data!: GetUserOutput[];
|
||||
data!: GetOrgUsersWithProfileOutput[];
|
||||
}
|
||||
|
||||
export class GetOrganizationUserOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
data!: GetUserOutput;
|
||||
data!: GetOrgUsersWithProfileOutput;
|
||||
}
|
||||
|
||||
+14
-2
@@ -24,7 +24,13 @@ export class OrganizationsEventTypesRepository {
|
||||
slug: eventTypeSlug,
|
||||
},
|
||||
},
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
hosts: true,
|
||||
destinationCalendar: true,
|
||||
team: { select: { bannerUrl: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,7 +39,13 @@ export class OrganizationsEventTypesRepository {
|
||||
where: {
|
||||
teamId,
|
||||
},
|
||||
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
|
||||
include: {
|
||||
users: true,
|
||||
schedule: true,
|
||||
hosts: true,
|
||||
destinationCalendar: true,
|
||||
team: { select: { bannerUrl: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,13 @@ export class OrganizationsUsersRepository {
|
||||
...this.filterOnOrgMembership(orgId),
|
||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
@@ -35,6 +42,13 @@ export class OrganizationsUsersRepository {
|
||||
username,
|
||||
...this.filterOnOrgMembership(orgId),
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,6 +58,13 @@ export class OrganizationsUsersRepository {
|
||||
email,
|
||||
...this.filterOnOrgMembership(orgId),
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +83,13 @@ export class OrganizationsUsersRepository {
|
||||
organizationId: orgId,
|
||||
},
|
||||
data: updateUserBody,
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,6 +99,13 @@ export class OrganizationsUsersRepository {
|
||||
id: userId,
|
||||
organizationId: orgId,
|
||||
},
|
||||
include: {
|
||||
profiles: {
|
||||
where: {
|
||||
organizationId: orgId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repos
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
|
||||
import { SchedulingType } from "@prisma/client";
|
||||
import { SchedulingType, Team } from "@prisma/client";
|
||||
|
||||
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
|
||||
|
||||
@@ -12,6 +12,7 @@ type EventTypeRelations = {
|
||||
schedule: Schedule | null;
|
||||
hosts: Host[];
|
||||
destinationCalendar?: DestinationCalendar | null;
|
||||
team?: Pick<Team, "bannerUrl"> | null;
|
||||
};
|
||||
export type DatabaseTeamEventType = EventType & EventTypeRelations;
|
||||
|
||||
@@ -66,6 +67,7 @@ type Input = Pick<
|
||||
| "eventName"
|
||||
| "useEventTypeDestinationCalendarEmail"
|
||||
| "hideCalendarEventDetails"
|
||||
| "team"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -76,12 +78,13 @@ export class OutputOrganizationsEventTypesService {
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
async getResponseTeamEventType(databaseEventType: Input) {
|
||||
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
|
||||
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
|
||||
0,
|
||||
databaseEventType
|
||||
databaseEventType,
|
||||
isOrgTeamEvent
|
||||
);
|
||||
const hosts =
|
||||
databaseEventType.schedulingType === "MANAGED"
|
||||
@@ -95,6 +98,7 @@ export class OutputOrganizationsEventTypesService {
|
||||
ownerId: userId,
|
||||
parentEventTypeId: parentId,
|
||||
schedulingType: databaseEventType.schedulingType,
|
||||
bannerUrl: databaseEventType?.team?.bannerUrl,
|
||||
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
|
||||
};
|
||||
}
|
||||
@@ -129,9 +133,14 @@ export class OutputOrganizationsEventTypesService {
|
||||
name: databaseUser?.name || "",
|
||||
mandatory: databaseHost.isFixed,
|
||||
priority: getPriorityLabel(databaseHost.priority || 2),
|
||||
avatarUrl: databaseUser?.avatarUrl,
|
||||
});
|
||||
} else {
|
||||
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
|
||||
transformedHosts.push({
|
||||
userId: databaseHost.userId,
|
||||
name: databaseUser?.name || "",
|
||||
avatarUrl: databaseUser?.avatarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation }
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries";
|
||||
import type { AvailableSlotsType } from "@calcom/platform-libraries";
|
||||
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
|
||||
@@ -89,9 +90,31 @@ export class SlotsController {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time", example: "2024-09-25T08:00:00.000Z" },
|
||||
},
|
||||
oneOf: [
|
||||
{
|
||||
properties: {
|
||||
time: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
example: "2024-09-25T08:00:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: {
|
||||
startTime: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
example: "2024-09-25T08:00:00.000Z",
|
||||
},
|
||||
endTime: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
example: "2024-09-25T08:30:00.000Z",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -102,11 +125,18 @@ export class SlotsController {
|
||||
status: "success",
|
||||
data: {
|
||||
slots: {
|
||||
// Default format (when slotFormat is 'time' or not provided)
|
||||
"2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }],
|
||||
// Alternative format (when slotFormat is 'range')
|
||||
"2024-09-26": [
|
||||
{ time: "2024-09-26T08:00:00.000Z" },
|
||||
{ time: "2024-09-26T08:15:00.000Z" },
|
||||
{ time: "2024-09-26T08:30:00.000Z" },
|
||||
{
|
||||
startTime: "2024-09-26T08:00:00.000Z",
|
||||
endTime: "2024-09-26T08:30:00.000Z",
|
||||
},
|
||||
{
|
||||
startTime: "2024-09-26T08:15:00.000Z",
|
||||
endTime: "2024-09-26T08:45:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -129,8 +159,20 @@ export class SlotsController {
|
||||
},
|
||||
});
|
||||
|
||||
const transformedSlots =
|
||||
query.slotFormat === SlotFormat.Range
|
||||
? await this.slotsService.formatSlots(
|
||||
availableSlots,
|
||||
query.duration,
|
||||
query.eventTypeId,
|
||||
query.slotFormat
|
||||
)
|
||||
: availableSlots.slots;
|
||||
|
||||
return {
|
||||
data: availableSlots,
|
||||
data: {
|
||||
slots: transformedSlots,
|
||||
},
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
|
||||
import { SlotsRepository } from "@/modules/slots/slots.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { SlotFormat } from "@calcom/platform-enums";
|
||||
import { ReserveSlotInput } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
@@ -54,4 +55,51 @@ export class SlotsService {
|
||||
const event = await this.eventTypeRepo.getEventTypeById(eventTypeId);
|
||||
return !!event?.teamId;
|
||||
}
|
||||
|
||||
async getEventTypeWithDuration(eventTypeId: number) {
|
||||
return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
|
||||
}
|
||||
|
||||
async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
|
||||
if (duration) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
if (eventTypeId) {
|
||||
const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
|
||||
if (!eventType) {
|
||||
throw new Error("Event type not found");
|
||||
}
|
||||
return eventType.length;
|
||||
}
|
||||
|
||||
throw new Error("duration or eventTypeId is required");
|
||||
}
|
||||
|
||||
async formatSlots(
|
||||
availableSlots: { slots: Record<string, { time: string }[]> },
|
||||
duration?: number,
|
||||
eventTypeId?: number,
|
||||
slotFormat?: SlotFormat
|
||||
): Promise<Record<string, { startTime: string; endTime: string }[]>> {
|
||||
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
|
||||
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
|
||||
}
|
||||
|
||||
const slotDuration = await this.getDuration(duration, eventTypeId);
|
||||
|
||||
return Object.entries(availableSlots.slots).reduce<
|
||||
Record<string, { startTime: string; endTime: string }[]>
|
||||
>((acc, [date, slots]) => {
|
||||
acc[date] = (slots as { time: string }[]).map((slot) => {
|
||||
const startTime = new Date(slot.time);
|
||||
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
|
||||
return {
|
||||
startTime: startTime.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
|
||||
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
|
||||
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
|
||||
import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output";
|
||||
import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { EventType, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomNumber } from "test/utils/randomNumber";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { api } from "@calcom/app-store/alby";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import { Membership, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Teams Memberships Endpoints", () => {
|
||||
describe("User Authentication - User is Team Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let profileRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let team: Team;
|
||||
let teamEventType: EventType;
|
||||
let managedEventType: EventType;
|
||||
let teamAdminMembership: Membership;
|
||||
let teamMemberMembership: Membership;
|
||||
let membershipCreatedViaApi: TeamMembershipOutput;
|
||||
|
||||
const teamAdminEmail = `alice-admin-${randomNumber()}@api.com`;
|
||||
const teamMemberEmail = `bob-member-${randomNumber()}@api.com`;
|
||||
const nonTeamUserEmail = `charlie-outsider-${randomNumber()}@api`;
|
||||
|
||||
const invitedUserEmail = `david-invited-${randomNumber()}@api.com`;
|
||||
|
||||
let teamAdmin: User;
|
||||
let teamMember: User;
|
||||
let nonTeamUser: User;
|
||||
|
||||
let teammateInvitedViaApi: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
teamAdminEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
|
||||
teamAdmin = await userRepositoryFixture.create({
|
||||
email: teamAdminEmail,
|
||||
username: teamAdminEmail,
|
||||
});
|
||||
teamMember = await userRepositoryFixture.create({
|
||||
email: teamMemberEmail,
|
||||
username: teamMemberEmail,
|
||||
});
|
||||
nonTeamUser = await userRepositoryFixture.create({
|
||||
email: nonTeamUserEmail,
|
||||
username: nonTeamUserEmail,
|
||||
});
|
||||
teammateInvitedViaApi = await userRepositoryFixture.create({
|
||||
email: invitedUserEmail,
|
||||
username: invitedUserEmail,
|
||||
});
|
||||
|
||||
team = await teamsRepositoryFixture.create({
|
||||
name: `Team-${randomNumber()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "COLLECTIVE",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Collective Event Type",
|
||||
slug: "collective-event-type",
|
||||
length: 30,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
|
||||
schedulingType: "MANAGED",
|
||||
team: {
|
||||
connect: { id: team.id },
|
||||
},
|
||||
title: "Managed Event Type",
|
||||
slug: "managed-event-type",
|
||||
length: 60,
|
||||
assignAllTeamMembers: true,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
});
|
||||
|
||||
teamAdminMembership = await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: teamAdmin.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
});
|
||||
|
||||
teamMemberMembership = await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: teamMember.id } },
|
||||
team: { connect: { id: team.id } },
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamAdmin.id}`,
|
||||
username: teamAdminEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamAdmin.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await profileRepositoryFixture.create({
|
||||
uid: `usr-${teamMember.id}`,
|
||||
username: teamMemberEmail,
|
||||
organization: {
|
||||
connect: {
|
||||
id: team.id,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
connect: {
|
||||
id: teamMember.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should get all the memberships of the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
expect(responseBody.data[0].id).toEqual(teamAdminMembership.id);
|
||||
expect(responseBody.data[1].id).toEqual(teamMemberMembership.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not be able to access memberships if not part of the team", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/teams/9999/memberships`).expect(403);
|
||||
});
|
||||
|
||||
it("should get all the memberships of the org's team paginated", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships?skip=1&take=1`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipsOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data[0].id).toEqual(teamMemberMembership.id);
|
||||
expect(responseBody.data[0].userId).toEqual(teamMember.id);
|
||||
expect(responseBody.data.length).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should get membership of the team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships/${teamAdminMembership.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetTeamMembershipOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(teamAdminMembership.id);
|
||||
expect(responseBody.data.userId).toEqual(teamAdmin.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should have created the membership of the org's team and assigned team wide events", async () => {
|
||||
const createTeamMembershipBody: CreateTeamMembershipInput = {
|
||||
userId: teammateInvitedViaApi.id,
|
||||
accepted: true,
|
||||
role: "MEMBER",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post(`/v2/teams/${team.id}/memberships`)
|
||||
.send(createTeamMembershipBody)
|
||||
.expect(201)
|
||||
.then((response) => {
|
||||
const responseBody: CreateTeamMembershipOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.teamId).toEqual(team.id);
|
||||
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
|
||||
expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id);
|
||||
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
|
||||
});
|
||||
});
|
||||
|
||||
async function userHasCorrectEventTypes(userId: number) {
|
||||
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
|
||||
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
|
||||
expect(managedEventTypes?.length).toEqual(1);
|
||||
expect(teamEventTypes?.length).toEqual(2);
|
||||
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
|
||||
expect(collectiveEvenType).toBeTruthy();
|
||||
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
|
||||
expect(userHost).toBeTruthy();
|
||||
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
|
||||
}
|
||||
|
||||
it("should update the membership of the org's team", async () => {
|
||||
const updateTeamMembershipBody: UpdateTeamMembershipInput = {
|
||||
role: "OWNER",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.send(updateTeamMembershipBody)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: UpdateTeamMembershipOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
membershipCreatedViaApi = responseBody.data;
|
||||
expect(membershipCreatedViaApi.role).toEqual("OWNER");
|
||||
});
|
||||
});
|
||||
|
||||
it("should delete the membership of the org's team we created via api", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.delete(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail to get the membership of the org's team we just deleted", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should fail if the membership does not exist", async () => {
|
||||
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
|
||||
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
|
||||
await userRepositoryFixture.deleteByEmail(nonTeamUser.email);
|
||||
await userRepositoryFixture.deleteByEmail(teamMember.email);
|
||||
await teamsRepositoryFixture.delete(team.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
|
||||
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
|
||||
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
|
||||
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
|
||||
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
|
||||
import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output";
|
||||
import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output";
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import {
|
||||
Controller,
|
||||
UseGuards,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Query,
|
||||
Delete,
|
||||
Patch,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
|
||||
import { SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/memberships",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, RolesGuard)
|
||||
@DocsTags("Teams / Memberships")
|
||||
export class TeamsMembershipsController {
|
||||
private logger = new Logger("TeamsMembershipsController");
|
||||
|
||||
constructor(
|
||||
private teamsMembershipsService: TeamsMembershipsService,
|
||||
private oganizationsEventTypesService: OrganizationsEventTypesService
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: "Create a membership" })
|
||||
async createTeamMembership(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Body() body: CreateTeamMembershipInput
|
||||
): Promise<CreateTeamMembershipOutput> {
|
||||
const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body);
|
||||
if (membership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(body.userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:membershipId")
|
||||
@ApiOperation({ summary: "Get a membership" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getTeamMembership(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<GetTeamMembershipOutput> {
|
||||
const orgTeamMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/")
|
||||
@ApiOperation({ summary: "Get all memberships" })
|
||||
@Roles("TEAM_ADMIN")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async getTeamMemberships(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetTeamMembershipsOutput> {
|
||||
const { skip, take } = queryParams;
|
||||
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
|
||||
teamId,
|
||||
skip ?? 0,
|
||||
take ?? 250
|
||||
);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: orgTeamMemberships.map((membership) =>
|
||||
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Patch("/:membershipId")
|
||||
@ApiOperation({ summary: "Create a membership" })
|
||||
async updateTeamMembership(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number,
|
||||
@Body() body: UpdateTeamMembershipInput
|
||||
): Promise<UpdateTeamMembershipOutput> {
|
||||
const membership = await this.teamsMembershipsService.updateTeamMembership(teamId, membershipId, body);
|
||||
|
||||
const currentMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId);
|
||||
|
||||
const updatedMembership = await this.teamsMembershipsService.updateTeamMembership(
|
||||
teamId,
|
||||
membershipId,
|
||||
body
|
||||
);
|
||||
|
||||
if (!currentMembership.accepted && updatedMembership.accepted) {
|
||||
try {
|
||||
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
|
||||
} catch (err) {
|
||||
this.logger.error("Could not update new team member eventTypes", err);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Delete("/:membershipId")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Delete a membership" })
|
||||
async deleteTeamMembership(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("membershipId", ParseIntPipe) membershipId: number
|
||||
): Promise<DeleteTeamMembershipOutput> {
|
||||
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
|
||||
|
||||
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
|
||||
|
||||
export class CreateTeamMembershipInput {
|
||||
@IsInt()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly accepted?: boolean = false;
|
||||
|
||||
@IsEnum(MembershipRole)
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
readonly role: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly disableImpersonation?: boolean = false;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { MembershipRole } from "@prisma/client";
|
||||
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
|
||||
|
||||
export class UpdateTeamMembershipInput {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly accepted?: boolean = false;
|
||||
|
||||
@IsEnum(MembershipRole)
|
||||
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
|
||||
readonly role?: MembershipRole = MembershipRole.MEMBER;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly disableImpersonation?: boolean = false;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class CreateTeamMembershipOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class DeleteTeamMembershipOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class GetTeamMembershipOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ApiProperty({
|
||||
type: TeamMembershipOutput,
|
||||
})
|
||||
@IsNotEmptyObject()
|
||||
@ValidateNested()
|
||||
@Type(() => TeamMembershipOutput)
|
||||
data!: TeamMembershipOutput;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user