2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
# Before adding new endpoints here
We're deprecating adding new endpoints in favor of creating tRPC procedures.
You can learn about [tRPC procedures in the docs](https://trpc.io/docs/v10/procedures).
You can see our current tRPC procedures in this file `packages/trpc/server/routers/_app.ts`

View File

@@ -0,0 +1,5 @@
import NextAuth from "next-auth";
import { AUTH_OPTIONS } from "@calcom/features/auth/lib/next-auth-options";
export default NextAuth(AUTH_OPTIONS);

View File

@@ -0,0 +1,70 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
if (!session || !session.user || !session.user.email) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const user = await prisma.user.findFirst({
where: {
email: session.user.email,
},
select: {
id: true,
password: true,
identityProvider: true,
},
});
if (!user) {
res.status(404).json({ message: "User not found" });
return;
}
if (user.identityProvider !== IdentityProvider.CAL) {
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const currentPassword = user.password?.hash;
if (!currentPassword) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
const passwordsMatch = await verifyPassword(oldPassword, currentPassword);
if (!passwordsMatch) {
return res.status(403).json({ error: ErrorCode.IncorrectPassword });
}
if (oldPassword === newPassword) {
return res.status(400).json({ error: ErrorCode.NewPasswordMatchesOld });
}
const hashedPassword = await hashPassword(newPassword);
await prisma.userPassword.upsert({
where: {
userId: user.id,
},
create: {
hash: hashedPassword,
userId: user.id,
},
update: {
hash: hashedPassword,
},
});
res.status(200).json({ message: "Password updated successfully" });
}

View File

@@ -0,0 +1,52 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { passwordResetRequest } from "@calcom/features/auth/lib/passwordResetRequest";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const email = z
.string()
.email()
.transform((val) => val.toLowerCase())
.safeParse(req.body?.email);
if (!email.success) {
return res.status(400).json({ message: "email is required" });
}
// fallback to email if ip is not present
let ip = (req.headers["x-real-ip"] as string) ?? email.data;
const forwardedFor = req.headers["x-forwarded-for"] as string;
if (!ip && forwardedFor) {
ip = forwardedFor?.split(",").at(0) ?? email.data;
}
// 10 requests per minute
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: ip,
});
try {
const user = await prisma.user.findUnique({
where: { email: email.data },
select: { name: true, email: true, locale: true },
});
// Don't leak info about whether the user exists
if (!user) return res.status(201).json({ message: "password_reset_email_sent" });
await passwordResetRequest(user);
return res.status(201).json({ message: "password_reset_email_sent" });
} catch (reason) {
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
}
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,14 @@
import type { NextApiRequest, NextApiResponse } from "next";
import isAuthorized from "@calcom/features/auth/lib/oAuthAuthorization";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const requiredScopes = ["READ_PROFILE"];
const account = await isAuthorized(req, requiredScopes);
if (!account) {
return res.status(401).json({ message: "Unauthorized" });
}
return res.status(201).json({ username: account.name });
}

View File

@@ -0,0 +1,69 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const refreshToken = req.headers.authorization?.split(" ")[1] || "";
const { client_id, client_secret, grant_type } = req.body;
if (grant_type !== "refresh_token") {
res.status(400).json({ message: "grant type invalid" });
return;
}
const [hashedSecret] = generateSecret(client_secret);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
if (!client) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
let decodedRefreshToken: OAuthTokenPayload;
try {
decodedRefreshToken = jwt.verify(refreshToken, secretKey) as OAuthTokenPayload;
} catch {
res.status(401).json({ message: "Unauthorized" });
return;
}
if (!decodedRefreshToken || decodedRefreshToken.token_type !== "Refresh Token") {
res.status(401).json({ message: "Unauthorized" });
return;
}
const payload: OAuthTokenPayload = {
userId: decodedRefreshToken.userId,
teamId: decodedRefreshToken.teamId,
scope: decodedRefreshToken.scope,
token_type: "Access Token",
clientId: client_id,
};
const access_token = jwt.sign(payload, secretKey, {
expiresIn: 1800, // 30 min
});
res.status(200).json({ access_token });
}

View File

@@ -0,0 +1,97 @@
import jwt from "jsonwebtoken";
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import { generateSecret } from "@calcom/trpc/server/routers/viewer/oAuth/addClient.handler";
import type { OAuthTokenPayload } from "@calcom/types/oauth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const { code, client_id, client_secret, grant_type, redirect_uri } = req.body;
if (grant_type !== "authorization_code") {
res.status(400).json({ message: "grant_type invalid" });
return;
}
const [hashedSecret] = generateSecret(client_secret);
const client = await prisma.oAuthClient.findFirst({
where: {
clientId: client_id,
clientSecret: hashedSecret,
},
select: {
redirectUri: true,
},
});
if (!client || client.redirectUri !== redirect_uri) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const accessCode = await prisma.accessCode.findFirst({
where: {
code: code,
clientId: client_id,
expiresAt: {
gt: new Date(),
},
},
});
//delete all expired accessCodes + the one that is used here
await prisma.accessCode.deleteMany({
where: {
OR: [
{
expiresAt: {
lt: new Date(),
},
},
{
code: code,
clientId: client_id,
},
],
},
});
if (!accessCode) {
res.status(401).json({ message: "Unauthorized" });
return;
}
const secretKey = process.env.CALENDSO_ENCRYPTION_KEY || "";
const payloadAuthToken: OAuthTokenPayload = {
userId: accessCode.userId,
teamId: accessCode.teamId,
scope: accessCode.scopes,
token_type: "Access Token",
clientId: client_id,
};
const payloadRefreshToken: OAuthTokenPayload = {
userId: accessCode.userId,
teamId: accessCode.teamId,
scope: accessCode.scopes,
token_type: "Refresh Token",
clientId: client_id,
};
const access_token = jwt.sign(payloadAuthToken, secretKey, {
expiresIn: 1800, // 30 min
});
const refresh_token = jwt.sign(payloadRefreshToken, secretKey, {
expiresIn: 30 * 24 * 60 * 60, // 30 days
});
res.status(200).json({ access_token, refresh_token });
}

View File

@@ -0,0 +1,36 @@
import type { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
// This is the callback endpoint for the OIDC provider
// A team must set this endpoint in the OIDC provider's configuration
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "GET") {
return res.status(400).send("Method not allowed");
}
const { code, state } = req.query as {
code: string;
state: string;
};
const { oauthController } = await jackson();
try {
const { redirect_url } = await oauthController.oidcAuthzResponse({ code, state });
if (!redirect_url) {
throw new HttpError({
message: "No redirect URL found",
statusCode: 500,
});
}
return res.redirect(302, redirect_url);
} catch (err) {
const { message, statusCode = 500 } = err as HttpError;
return res.status(statusCode).send(message);
}
}

View File

@@ -0,0 +1,75 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
const passwordResetRequestSchema = z.object({
password: z.string().refine(validPassword, () => ({
message: "Password does not meet the requirements",
})),
requestId: z.string(), // format doesn't matter.
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Bad Method when not POST
if (req.method !== "POST") return res.status(405).end();
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(req.body);
// rate-limited there is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options.
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
id: rawRequestId,
expires: {
gt: new Date(),
},
},
select: {
email: true,
},
});
const hashedPassword = await hashPassword(rawPassword);
// this can fail if a password request has been made for an email that has since changed or-
// never existed within Cal. In this case we do not want to disclose the email's existence.
// instead, we just return 404
try {
await prisma.user.update({
where: {
email: maybeRequest.email,
},
data: {
password: {
upsert: {
create: { hash: hashedPassword },
update: { hash: hashedPassword },
},
},
emailVerified: new Date(),
identityProvider: IdentityProvider.CAL,
identityProviderId: null,
},
});
} catch (e) {
return res.status(404).end();
}
await expireResetPasswordRequest(rawRequestId);
return res.status(201).json({ message: "Password reset." });
}
async function expireResetPasswordRequest(rawRequestId: string) {
await prisma.resetPasswordRequest.update({
where: {
id: rawRequestId,
},
data: {
// We set the expiry to now to invalidate the request
expires: new Date(),
},
});
}

View File

@@ -0,0 +1,23 @@
import type { OAuthReq } from "@boxyhq/saml-jackson";
import type { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import type { HttpError } from "@calcom/lib/http-error";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { oauthController } = await jackson();
if (req.method !== "GET") {
return res.status(400).send("Method not allowed");
}
try {
const { redirect_url } = await oauthController.authorize(req.query as unknown as OAuthReq);
return res.redirect(302, redirect_url as string);
} catch (err) {
const { message, statusCode = 500 } = err as HttpError;
return res.status(statusCode).send(message);
}
}

View File

@@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const { oauthController } = await jackson();
const { redirect_url } = await oauthController.samlResponse(req.body);
if (redirect_url) {
res.redirect(302, redirect_url);
}
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@@ -0,0 +1,13 @@
import type { NextApiRequest } from "next";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
async function postHandler(req: NextApiRequest) {
const { oauthController } = await jackson();
return await oauthController.token(req.body);
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});

View File

@@ -0,0 +1,34 @@
import type { NextApiRequest } from "next";
import z from "zod";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
const extractAuthToken = (req: NextApiRequest) => {
const authHeader = req.headers["authorization"];
const parts = (authHeader || "").split(" ");
if (parts.length > 1) return parts[1];
// check for query param
let arr: string[] = [];
const { access_token } = requestQuery.parse(req.query);
arr = arr.concat(access_token);
if (arr[0].length > 0) return arr[0];
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
};
const requestQuery = z.object({
access_token: z.string(),
});
async function getHandler(req: NextApiRequest) {
const { oauthController } = await jackson();
const token = extractAuthToken(req);
return await oauthController.userInfo(token);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
});

View File

@@ -0,0 +1,58 @@
import type { NextApiRequest } from "next";
import z from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
const querySchema = z.object({
username: z
.string()
.refine((val) => val.trim().length >= 1, { message: "Please enter at least one character" }),
full_name: z.string().min(3, "Please enter at least 3 characters"),
email_address: z.string().email({ message: "Please enter a valid email" }),
password: z.string().refine((val) => isPasswordValid(val.trim(), false, true), {
message:
"The password must be a minimum of 15 characters long containing at least one number and have a mixture of uppercase and lowercase letters",
}),
});
async function handler(req: NextApiRequest) {
const userCount = await prisma.user.count();
if (userCount !== 0) {
throw new HttpError({ statusCode: 400, message: "No setup needed." });
}
const parsedQuery = querySchema.safeParse(req.body);
if (!parsedQuery.success) {
throw new HttpError({ statusCode: 422, message: parsedQuery.error.message });
}
const username = slugify(parsedQuery.data.username.trim());
const userEmail = parsedQuery.data.email_address.toLowerCase();
const hashedPassword = await hashPassword(parsedQuery.data.password);
await prisma.user.create({
data: {
username,
email: userEmail,
password: { create: { hash: hashedPassword } },
role: "ADMIN",
name: parsedQuery.data.full_name,
emailVerified: new Date(),
locale: "en", // TODO: We should revisit this
identityProvider: IdentityProvider.CAL,
},
});
return { message: "First admin user created successfully." };
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,71 @@
import type { NextApiResponse } from "next";
import calcomSignupHandler from "@calcom/feature-auth/signup/handlers/calcomHandler";
import selfHostedSignupHandler from "@calcom/feature-auth/signup/handlers/selfHostedHandler";
import { type RequestWithUsernameStatus } from "@calcom/features/auth/signup/username";
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { signupSchema } from "@calcom/prisma/zod-utils";
function ensureSignupIsEnabled(req: RequestWithUsernameStatus) {
const { token } = signupSchema
.pick({
token: true,
})
.parse(req.body);
// Stil allow signups if there is a team invite
if (token) return;
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true") {
throw new HttpError({
statusCode: 403,
message: "Signup is disabled",
});
}
}
function ensureReqIsPost(req: RequestWithUsernameStatus) {
if (req.method !== "POST") {
throw new HttpError({
statusCode: 405,
message: "Method not allowed",
});
}
}
export default async function handler(req: RequestWithUsernameStatus, res: NextApiResponse) {
const remoteIp = getIP(req);
// Use a try catch instead of returning res every time
try {
await checkCfTurnstileToken({
token: req.headers["cf-access-token"] as string,
remoteIp,
});
ensureReqIsPost(req);
ensureSignupIsEnabled(req);
/**
* Im not sure its worth merging these two handlers. They are different enough to be separate.
* Calcom handles things like creating a stripe customer - which we don't need to do for self hosted.
* It also handles things like premium username.
* TODO: (SEAN) - Extract a lot of the logic from calcomHandler into a separate file and import it into both handlers.
* @zomars: We need to be able to test this with E2E. They way it's done RN it will never run on CI.
*/
if (IS_PREMIUM_USERNAME_ENABLED) {
return await calcomSignupHandler(req, res);
}
return await selfHostedSignupHandler(req, res);
} catch (e) {
if (e instanceof HttpError) {
return res.status(e.statusCode).json({ message: e.message });
}
logger.error(e);
return res.status(500).json({ message: "Internal server error" });
}
}

View File

@@ -0,0 +1,114 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/client";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getServerSession({ req, res });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (!user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
if (!user.twoFactorEnabled) {
return res.json({ message: "Two factor disabled" });
}
if (user.password?.hash && user.identityProvider === IdentityProvider.CAL) {
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
}
// if user has 2fa and using backup code
if (user.twoFactorEnabled && req.body.backupCode) {
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with backup code login.");
throw new Error(ErrorCode.InternalServerError);
}
if (!user.backupCodes) {
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
}
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
// check if user-supplied code matches one
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
if (index === -1) {
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
}
// we delete all stored backup codes at the end, no need to do this here
// if user has 2fa and NOT using backup code, try totp
} else if (user.twoFactorEnabled) {
if (!req.body.code) {
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
// throw new Error(ErrorCode.SecondFactorRequired);
}
if (!user.twoFactorSecret) {
console.error(`Two factor is enabled for user ${user.id} but they have no secret`);
throw new Error(ErrorCode.InternalServerError);
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error(`"Missing encryption key; cannot proceed with two factor login."`);
throw new Error(ErrorCode.InternalServerError);
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
throw new Error(ErrorCode.InternalServerError);
}
// If user has 2fa enabled, check if body.code is correct
const isValidToken = totpAuthenticatorCheck(req.body.code, secret);
if (!isValidToken) {
return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode });
// throw new Error(ErrorCode.IncorrectTwoFactorCode);
}
}
// If it is, disable users 2fa
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
backupCodes: null,
twoFactorEnabled: false,
twoFactorSecret: null,
},
});
return res.json({ message: "Two factor disabled" });
}

View File

@@ -0,0 +1,66 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getServerSession({ req, res });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled });
}
if (!user.twoFactorSecret) {
return res.status(400).json({ error: ErrorCode.TwoFactorSetupRequired });
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with two factor setup.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const secret = symmetricDecrypt(user.twoFactorSecret, process.env.CALENDSO_ENCRYPTION_KEY);
if (secret.length !== 32) {
console.error(
`Two factor secret decryption failed. Expected key with length 32 but got ${secret.length}`
);
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isValidToken = totpAuthenticatorCheck(req.body.code, secret);
if (!isValidToken) {
return res.status(400).json({ error: ErrorCode.IncorrectTwoFactorCode });
}
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
twoFactorEnabled: true,
},
});
return res.json({ message: "Two-factor enabled" });
}

View File

@@ -0,0 +1,79 @@
import crypto from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { authenticator } from "otplib";
import qrcode from "qrcode";
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import { symmetricEncrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
import { IdentityProvider } from "@calcom/prisma/enums";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(405).json({ message: "Method not allowed" });
}
const session = await getServerSession({ req, res });
if (!session) {
return res.status(401).json({ message: "Not authenticated" });
}
if (!session.user?.id) {
console.error("Session is missing a user id.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id }, include: { password: true } });
if (!user) {
console.error(`Session references user that no longer exists.`);
return res.status(401).json({ message: "Not authenticated" });
}
if (user.identityProvider !== IdentityProvider.CAL && !user.password?.hash) {
return res.status(400).json({ error: ErrorCode.ThirdPartyIdentityProviderEnabled });
}
if (!user.password?.hash) {
return res.status(400).json({ error: ErrorCode.UserMissingPassword });
}
if (user.twoFactorEnabled) {
return res.status(400).json({ error: ErrorCode.TwoFactorAlreadyEnabled });
}
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
console.error("Missing encryption key; cannot proceed with two factor setup.");
return res.status(500).json({ error: ErrorCode.InternalServerError });
}
const isCorrectPassword = await verifyPassword(req.body.password, user.password.hash);
if (!isCorrectPassword) {
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
}
// This generates a secret 32 characters in length. Do not modify the number of
// bytes without updating the sanity checks in the enable and login endpoints.
const secret = authenticator.generateSecret(20);
// generate backup codes with 10 character length
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
await prisma.user.update({
where: {
id: session.user.id,
},
data: {
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
twoFactorEnabled: false,
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
},
});
const name = user.email || user.username || user.id.toString();
const keyUri = authenticator.keyuri(name, "Cal", secret);
const dataUri = await qrcode.toDataURL(keyUri);
return res.json({ secret, keyUri, dataUri, backupCodes });
}

View File

@@ -0,0 +1,82 @@
import { organizationScenarios } from "@calcom/lib/server/repository/__mocks__/organization";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MembershipRole } from "@calcom/prisma/client";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
import { moveUserToMatchingOrg } from "./verify-email";
vi.mock("@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler");
vi.mock("@calcom/lib/server/repository/organization");
describe("moveUserToMatchingOrg", () => {
const email = "test@example.com";
beforeEach(() => {
vi.resetAllMocks();
});
it("should not proceed if no matching organization is found", async () => {
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeNoMatch();
await moveUserToMatchingOrg({ email });
expect(inviteMembersWithNoInviterPermissionCheck).not.toHaveBeenCalled();
});
describe("should invite user to the matching organization", () => {
const argToInviteMembersWithNoInviterPermissionCheck = {
inviterName: null,
language: "en",
invitations: [
{
usernameOrEmail: email,
role: MembershipRole.MEMBER,
},
],
};
it("when organization has a slug and requestedSlug(slug is used)", async () => {
const org = {
id: "org123",
slug: "test-org",
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
await moveUserToMatchingOrg({ email });
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith({
...argToInviteMembersWithNoInviterPermissionCheck,
teamId: org.id,
orgSlug: org.slug,
});
});
it("when organization has requestedSlug only", async () => {
const org = {
id: "org123",
slug: null,
requestedSlug: "requested-test-org",
};
organizationScenarios.OrganizationRepository.findUniqueByMatchingAutoAcceptEmail.fakeReturnOrganization(
org,
{ email }
);
await moveUserToMatchingOrg({ email });
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith({
...argToInviteMembersWithNoInviterPermissionCheck,
teamId: org.id,
orgSlug: org.requestedSlug,
});
});
});
});

View File

@@ -0,0 +1,181 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
const verifySchema = z.object({
token: z.string(),
});
const USER_ALREADY_EXISTING_MESSAGE = "A User already exists with this email";
// TODO: To be unit tested
export async function moveUserToMatchingOrg({ email }: { email: string }) {
const org = await OrganizationRepository.findUniqueByMatchingAutoAcceptEmail({ email });
if (!org) {
return;
}
await inviteMembersWithNoInviterPermissionCheck({
inviterName: null,
teamId: org.id,
language: "en",
invitations: [
{
usernameOrEmail: email,
role: MembershipRole.MEMBER,
},
],
orgSlug: org.slug || org.requestedSlug,
});
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { token } = verifySchema.parse(req.query);
const foundToken = await prisma.verificationToken.findFirst({
where: {
token,
},
});
if (!foundToken) {
return res.status(401).json({ message: "No token found" });
}
if (dayjs(foundToken?.expires).isBefore(dayjs())) {
return res.status(401).json({ message: "Token expired" });
}
// The user is verifying the secondary email
if (foundToken?.secondaryEmailId) {
await prisma.secondaryEmail.update({
where: {
id: foundToken.secondaryEmailId,
email: foundToken?.identifier,
},
data: {
emailVerified: new Date(),
},
});
await cleanUpVerificationTokens(foundToken.id);
return res.redirect(`${WEBAPP_URL}/settings/my-account/profile`);
}
const user = await prisma.user.findFirst({
where: {
email: foundToken?.identifier,
},
});
if (!user) {
return res.status(401).json({ message: "Cannot find a user attached to this token" });
}
const userMetadataParsed = userMetadata.parse(user.metadata);
// Attach the new email and verify
if (userMetadataParsed?.emailChangeWaitingForVerification) {
// Ensure this email isnt in use
const existingUser = await prisma.user.findUnique({
where: { email: userMetadataParsed?.emailChangeWaitingForVerification },
select: {
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
// Ensure this email isn't being added by another user as secondary email
const existingSecondaryUser = await prisma.secondaryEmail.findUnique({
where: {
email: userMetadataParsed?.emailChangeWaitingForVerification,
},
select: {
id: true,
userId: true,
},
});
if (existingSecondaryUser && existingSecondaryUser.userId !== user.id) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
const oldEmail = user.email;
const updatedEmail = userMetadataParsed.emailChangeWaitingForVerification;
delete userMetadataParsed.emailChangeWaitingForVerification;
// Update and re-verify
await prisma.user.update({
where: {
id: user.id,
},
data: {
email: updatedEmail,
metadata: userMetadataParsed,
},
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
await stripe.customers.update(userMetadataParsed.stripeCustomerId, {
email: updatedEmail,
});
}
// The user is trying to update the email to an already existing unverified secondary email of his
// so we swap the emails and its verified status
if (existingSecondaryUser?.userId === user.id) {
await prisma.secondaryEmail.update({
where: {
id: existingSecondaryUser.id,
userId: user.id,
},
data: {
email: oldEmail,
emailVerified: user.emailVerified,
},
});
}
await cleanUpVerificationTokens(foundToken.id);
return res.status(200).json({
updatedEmail,
});
}
await prisma.user.update({
where: {
id: user.id,
},
data: {
emailVerified: new Date(),
},
});
const hasCompletedOnboarding = user.completedOnboarding;
await moveUserToMatchingOrg({ email: user.email });
return res.redirect(`${WEBAPP_URL}/${hasCompletedOnboarding ? "/event-types" : "/getting-started"}`);
}
export async function cleanUpVerificationTokens(id: number) {
// Delete token from DB after it has been used
await prisma.verificationToken.delete({
where: {
id,
},
});
}

View File

@@ -0,0 +1,100 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import notEmpty from "@calcom/lib/notEmpty";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
const selectedCalendarSelectSchema = z.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.number().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const userWithCredentials = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
credentials: {
select: credentialForCalendarServiceSelect,
},
timeZone: true,
id: true,
selectedCalendars: true,
},
});
if (!userWithCredentials) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const { credentials, ...user } = userWithCredentials;
if (req.method === "POST") {
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId: user.id,
integration,
externalId,
},
},
create: {
userId: user.id,
integration,
externalId,
credentialId,
},
// already exists
update: {},
});
res.status(200).json({ message: "Calendar Selection Saved" });
}
if (req.method === "DELETE") {
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.query);
await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: user.id,
externalId,
integration,
},
},
});
res.status(200).json({ message: "Calendar Selection Saved" });
}
if (req.method === "GET") {
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
where: {
userId: user.id,
},
select: {
externalId: true,
},
});
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
res.status(200).json(selectableCalendars);
}
}

View File

@@ -0,0 +1,62 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
const querySchema = z.object({
uuid: z.string().transform((objectKey) => objectKey.split(".")[0]),
});
const handleValidationError = (res: NextApiResponse, error: z.ZodError): void => {
const errors = error.errors.map((err) => ({
path: err.path.join("."),
errorCode: `error.validation.${err.code}`,
}));
res.status(400).json({
message: "VALIDATION_ERROR",
errors,
});
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const result = querySchema.safeParse(req.query);
if (!result.success) {
return handleValidationError(res, result.error);
}
const { uuid: objectKey } = result.data;
let img;
try {
const { data } = await prisma.avatar.findUniqueOrThrow({
where: {
objectKey,
},
select: {
data: true,
},
});
img = data;
} catch (e) {
// If anything goes wrong or avatar is not found, use default avatar
res.writeHead(302, {
Location: AVATAR_FALLBACK,
});
res.end();
return;
}
const decoded = img.toString().replace("data:image/png;base64,", "").replace("data:image/jpeg;base64,", "");
const imageResp = Buffer.from(decoded, "base64");
res.writeHead(200, {
"Content-Type": "image/png",
"Content-Length": imageResp.length,
"Cache-Control": "max-age=86400",
});
res.end(imageResp);
}

View File

@@ -0,0 +1,24 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
});
const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
const booking = await handleNewBooking(req);
return booking;
}
export default defaultResponder(handler);

View File

@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleInstantMeeting from "@calcom/features/instant-meeting/handleInstantMeeting";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `instant.event-${userIp}`,
});
const session = await getServerSession({ req, res });
req.userId = session?.user?.id || -1;
const booking = await handleInstantMeeting(req);
return booking;
}
export default defaultResponder(handler);

View File

@@ -0,0 +1,30 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { handleNewRecurringBooking } from "@calcom/features/bookings/lib/handleNewRecurringBooking";
import type { BookingResponse } from "@calcom/features/bookings/types";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { defaultResponder } from "@calcom/lib/server";
// @TODO: Didn't look at the contents of this function in order to not break old booking page.
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const userIp = getIP(req);
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: userIp,
});
const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(req);
return createdBookings;
}
export const handleRecurringEventBooking = handler;
export default defaultResponder(handler);

View File

@@ -0,0 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import { defaultResponder, defaultHandler } from "@calcom/lib/server";
async function handler(req: NextApiRequest & { userId?: number }, res: NextApiResponse) {
const session = await getServerSession({ req, res });
/* To mimic API behavior and comply with types */
req.userId = session?.user?.id || -1;
return await handleCancelBooking(req);
}
export default defaultHandler({
DELETE: Promise.resolve({ default: defaultResponder(handler) }),
POST: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,9 @@
import { collectApiHandler } from "next-collect/server";
import { extendEventData, nextCollectBasicSettings } from "@calcom/lib/telemetry";
export default collectApiHandler({
...nextCollectBasicSettings,
cookieName: "__clnds",
extend: extendEventData,
});

View File

@@ -0,0 +1,146 @@
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { sendOrganizerRequestReminderEmail } from "@calcom/emails";
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import { BookingStatus, ReminderType } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const reminderIntervalMinutes = [48 * 60, 24 * 60, 3 * 60];
let notificationsSent = 0;
for (const interval of reminderIntervalMinutes) {
const bookings = await prisma.booking.findMany({
where: {
status: BookingStatus.PENDING,
createdAt: {
lte: dayjs().add(-interval, "minutes").toDate(),
},
// Only send reminders if the event hasn't finished
endTime: { gte: new Date() },
OR: [
// no payment required
{
payment: { none: {} },
},
// paid but awaiting approval
{
payment: { some: {} },
paid: true,
},
],
},
select: {
...bookingMinimalSelect,
location: true,
user: {
select: {
id: true,
email: true,
name: true,
username: true,
locale: true,
timeZone: true,
destinationCalendar: true,
},
},
eventType: {
select: {
recurringEvent: true,
bookingFields: true,
},
},
responses: true,
uid: true,
destinationCalendar: true,
},
});
const reminders = await prisma.reminderMail.findMany({
where: {
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
referenceId: {
in: bookings.map((b) => b.id),
},
elapsedMinutes: {
gte: interval,
},
},
});
for (const booking of bookings.filter((b) => !reminders.some((r) => r.referenceId == b.id))) {
const { user } = booking;
const name = user?.name || user?.username;
if (!user || !name || !user.timeZone) {
console.error(`Booking ${booking.id} is missing required properties for booking reminder`, { user });
continue;
}
const tOrganizer = await getTranslation(user.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
bookingFields: booking.eventType?.bookingFields ?? null,
booking,
}),
location: booking.location ?? "",
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
id: user.id,
email: booking?.userPrimaryEmail ?? user.email,
name,
timeZone: user.timeZone,
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};
await sendOrganizerRequestReminderEmail(evt);
await prisma.reminderMail.create({
data: {
referenceId: booking.id,
reminderType: ReminderType.PENDING_BOOKING_CONFIRMATION,
elapsedMinutes: interval,
},
});
notificationsSent++;
}
}
res.status(200).json({ notificationsSent });
}

View File

@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
expiresAt: {
lte: new Date(Date.now()),
},
},
});
res.json({ ok: true, count: deleted.count });
}

View File

@@ -0,0 +1,173 @@
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { getDefaultScheduleId } from "@calcom/trpc/server/routers/viewer/availability/util";
const travelScheduleSelect = {
id: true,
startDate: true,
endDate: true,
timeZone: true,
prevTimeZone: true,
user: {
select: {
id: true,
timeZone: true,
defaultScheduleId: true,
},
},
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
let timeZonesChanged = 0;
const setNewTimeZone = async (timeZone: string, user: { id: number; defaultScheduleId: number | null }) => {
await prisma.user.update({
where: {
id: user.id,
},
data: {
timeZone: timeZone,
},
});
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
// set default schedule if not already set
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
await prisma.schedule.updateMany({
where: {
id: defaultScheduleId,
},
data: {
timeZone: timeZone,
},
});
timeZonesChanged++;
};
/* travelSchedules should be deleted automatically when timezone is set back to original tz,
but we do this in case there cron job didn't run for some reason
*/
const schedulesToDelete = await prisma.travelSchedule.findMany({
where: {
OR: [
{
startDate: {
lt: dayjs.utc().subtract(2, "day").toDate(),
},
endDate: null,
},
{
endDate: {
lt: dayjs.utc().subtract(2, "day").toDate(),
},
},
],
},
select: travelScheduleSelect,
});
for (const travelSchedule of schedulesToDelete) {
if (travelSchedule.prevTimeZone) {
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
}
await prisma.travelSchedule.delete({
where: {
id: travelSchedule.id,
},
});
}
const travelSchedulesCloseToCurrentDate = await prisma.travelSchedule.findMany({
where: {
OR: [
{
startDate: {
gte: dayjs.utc().subtract(1, "day").toDate(),
lte: dayjs.utc().add(1, "day").toDate(),
},
},
{
endDate: {
gte: dayjs.utc().subtract(1, "day").toDate(),
lte: dayjs.utc().add(1, "day").toDate(),
},
},
],
},
select: travelScheduleSelect,
});
const travelScheduleIdsToDelete = [];
for (const travelSchedule of travelSchedulesCloseToCurrentDate) {
const userTz = travelSchedule.user.timeZone;
const offset = dayjs().tz(userTz).utcOffset();
// midnight of user's time zone in utc time
const startDateUTC = dayjs(travelSchedule.startDate).subtract(offset, "minute");
// 23:59 of user's time zone in utc time
const endDateUTC = dayjs(travelSchedule.endDate).subtract(offset, "minute");
if (
!dayjs.utc().isBefore(startDateUTC) &&
dayjs.utc().isBefore(endDateUTC) &&
!travelSchedule.prevTimeZone
) {
// if travel schedule has started and prevTimeZone is not yet set, we need to change time zone
await setNewTimeZone(travelSchedule.timeZone, travelSchedule.user);
if (!travelSchedule.endDate) {
travelScheduleIdsToDelete.push(travelSchedule.id);
} else {
await prisma.travelSchedule.update({
where: {
id: travelSchedule.id,
},
data: {
prevTimeZone: travelSchedule.user.timeZone,
},
});
}
}
if (!dayjs.utc().isBefore(endDateUTC)) {
if (travelSchedule.prevTimeZone) {
// travel schedule ended, change back to original timezone
await setNewTimeZone(travelSchedule.prevTimeZone, travelSchedule.user);
}
travelScheduleIdsToDelete.push(travelSchedule.id);
}
}
await prisma.travelSchedule.deleteMany({
where: {
id: {
in: travelScheduleIdsToDelete,
},
},
});
res.status(200).json({ timeZonesChanged });
}

View File

@@ -0,0 +1,53 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
},
select: {
id: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
await updateQuantitySubscriptionFromStripe(team.id);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}

View File

@@ -0,0 +1,315 @@
import type { Prisma } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { sendMonthlyDigestEmails } from "@calcom/emails/email-manager";
import { EventsInsights } from "@calcom/features/insights/server/events";
import { getTranslation } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
const querySchema = z.object({
page: z.coerce.number().min(0).optional().default(0),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const pageSize = 90; // Adjust this value based on the total number of teams and the available processing time
let { page: pageNumber } = querySchema.parse(req.query);
const firstDateOfMonth = new Date();
firstDateOfMonth.setDate(1);
while (true) {
const teams = await prisma.team.findMany({
where: {
slug: {
not: null,
},
createdAt: {
// created before or on the first day of this month
lte: firstDateOfMonth,
},
},
select: {
id: true,
createdAt: true,
members: true,
name: true,
},
skip: pageNumber * pageSize,
take: pageSize,
});
if (teams.length === 0) {
break;
}
for (const team of teams) {
const EventData: {
Created: number;
Completed: number;
Rescheduled: number;
Cancelled: number;
mostBookedEvents: {
eventTypeId?: number | null;
eventTypeName?: string | null;
count?: number | null;
}[];
membersWithMostBookings: {
userId: number | null;
user: {
id: number;
name: string | null;
email: string;
avatar: string | null;
username: string | null;
};
count: number;
}[];
admin: { email: string; name: string };
team: {
name: string;
id: number;
};
} = {
Created: 0,
Completed: 0,
Rescheduled: 0,
Cancelled: 0,
mostBookedEvents: [],
membersWithMostBookings: [],
admin: { email: "", name: "" },
team: { name: team.name, id: team.id },
};
const userIdsFromTeams = team.members.map((u) => u.userId);
// Booking Events
const whereConditional: Prisma.BookingTimeStatusWhereInput = {
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
createdAt: {
gte: dayjs(firstDateOfMonth).toISOString(),
lte: dayjs(new Date()).toISOString(),
},
};
const countGroupedByStatus = await EventsInsights.countGroupedByStatus(whereConditional);
EventData["Created"] = countGroupedByStatus["_all"];
EventData["Completed"] = countGroupedByStatus["completed"];
EventData["Rescheduled"] = countGroupedByStatus["rescheduled"];
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
createdAt: {
gte: dayjs(firstDateOfMonth).startOf("day").toDate(),
lte: dayjs(new Date()).endOf("day").toDate(),
},
OR: [
{
teamId: team.id,
},
{
userId: {
in: userIdsFromTeams,
},
teamId: null,
},
],
};
const bookingsFromSelected = await prisma.bookingTimeStatus.groupBy({
by: ["eventTypeId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const eventTypeIds = bookingsFromSelected
.filter((booking) => typeof booking.eventTypeId === "number")
.map((booking) => booking.eventTypeId);
const eventTypeWhereConditional: Prisma.EventTypeWhereInput = {
id: {
in: eventTypeIds as number[],
},
};
const eventTypesFrom = await prisma.eventType.findMany({
select: {
id: true,
title: true,
teamId: true,
userId: true,
slug: true,
users: {
select: {
username: true,
},
},
team: {
select: {
slug: true,
},
},
},
where: eventTypeWhereConditional,
});
const eventTypeHashMap: Map<
number,
Prisma.EventTypeGetPayload<{
select: {
id: true;
title: true;
teamId: true;
userId: true;
slug: true;
users: {
select: {
username: true;
};
};
team: {
select: {
slug: true;
};
};
};
}>
> = new Map();
eventTypesFrom.forEach((eventType) => {
eventTypeHashMap.set(eventType.id, eventType);
});
EventData["mostBookedEvents"] = bookingsFromSelected.map((booking) => {
const eventTypeSelected = eventTypeHashMap.get(booking.eventTypeId ?? 0);
if (!eventTypeSelected) {
return {};
}
let eventSlug = "";
if (eventTypeSelected.userId) {
eventSlug = `${eventTypeSelected?.users[0]?.username}/${eventTypeSelected?.slug}`;
}
if (eventTypeSelected?.team && eventTypeSelected?.team?.slug) {
eventSlug = `${eventTypeSelected.team.slug}/${eventTypeSelected.slug}`;
}
return {
eventTypeId: booking.eventTypeId,
eventTypeName: eventSlug,
count: booking._count.id,
};
});
// Most booked members
const bookingsFromTeam = await prisma.bookingTimeStatus.groupBy({
by: ["userId"],
where: bookingWhere,
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
take: 10,
});
const userIds = bookingsFromTeam
.filter((booking) => typeof booking.userId === "number")
.map((booking) => booking.userId);
if (userIds.length === 0) {
EventData["membersWithMostBookings"] = [];
} else {
const teamUsers = await prisma.user.findMany({
where: {
id: {
in: userIds as number[],
},
},
select: { id: true, name: true, email: true, avatarUrl: true, username: true },
});
const userHashMap = new Map();
teamUsers.forEach((user) => {
userHashMap.set(user.id, user);
});
EventData["membersWithMostBookings"] = bookingsFromTeam.map((booking) => {
return {
userId: booking.userId,
user: userHashMap.get(booking.userId),
count: booking._count.id,
};
});
}
// Send mail to all Owners and Admins
const mailReceivers = team?.members?.filter(
(member) => member.role === "OWNER" || member.role === "ADMIN"
);
const mailsToSend = mailReceivers.map(async (receiver) => {
const owner = await prisma.user.findUnique({
where: {
id: receiver?.userId,
},
});
if (owner) {
const t = await getTranslation(owner?.locale ?? "en", "common");
// Only send email if user has allowed to receive monthly digest emails
if (owner.receiveMonthlyDigestEmail) {
await sendMonthlyDigestEmails({
...EventData,
admin: { email: owner?.email ?? "", name: owner?.name ?? "" },
language: t,
});
}
}
});
await Promise.all(mailsToSend);
await delay(100); // Adjust the delay as needed to avoid rate limiting
}
pageNumber++;
}
res.json({ ok: true });
}

View File

@@ -0,0 +1,67 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import logger from "@calcom/lib/logger";
import { prisma } from "@calcom/prisma";
import type { AppCategories, Prisma } from "@calcom/prisma/client";
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
const log = logger.getSubLogger({
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
});
/**
* syncAppMeta makes sure any app metadata that has been replicated into the database
* remains synchronized with any changes made to the app config files.
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (process.env.CRON_API_KEY !== apiKey) {
res.status(401).json({ message: "Not authenticated" });
return;
}
if (req.method !== "POST") {
res.status(405).json({ message: "Invalid method" });
return;
}
log.info(`🧐 Checking DB apps are in-sync with app metadata`);
const dbApps = await prisma.app.findMany();
for await (const dbApp of dbApps) {
const app = await getAppWithMetadata(dbApp);
const updates: Prisma.AppUpdateManyMutationInput = {};
if (!app) {
log.warn(`💀 App ${dbApp.slug} (${dbApp.dirName}) no longer exists.`);
continue;
}
// Check for any changes in the app categories (tolerates changes in ordering)
if (
dbApp.categories.length !== app.categories.length ||
!dbApp.categories.every((category) => app.categories.includes(category))
) {
updates["categories"] = app.categories as AppCategories[];
}
if (dbApp.dirName !== (app.dirName ?? app.slug)) {
updates["dirName"] = app.dirName ?? app.slug;
}
if (Object.keys(updates).length > 0) {
log.info(`🔨 Updating app ${dbApp.slug} with ${Object.keys(updates).join(", ")}`);
if (!isDryRun) {
await prisma.app.update({
where: { slug: dbApp.slug },
data: updates,
});
}
} else {
log.info(`✅ App ${dbApp.slug} is up-to-date and correct`);
}
}
res.json({ ok: true });
}

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/webhooks/lib/cron";

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/api/scheduleEmailReminders";

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/api/scheduleSMSReminders";

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/workflows/api/scheduleWhatsappReminders";

View File

@@ -0,0 +1,66 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { renderEmail } from "@calcom/emails";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
const t = await getTranslation("en", "common");
res.statusCode = 200;
res.setHeader("Content-Type", "text/html");
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
res.write(
await renderEmail("MonthlyDigestEmail", {
language: t,
Created: 12,
Completed: 13,
Rescheduled: 14,
Cancelled: 16,
mostBookedEvents: [
{
eventTypeId: 3,
eventTypeName: "Test1",
count: 3,
},
{
eventTypeId: 4,
eventTypeName: "Test2",
count: 5,
},
],
membersWithMostBookings: [
{
userId: 4,
user: {
id: 4,
name: "User1 name",
email: "email.com",
avatar: "none",
username: "User1",
},
count: 4,
},
{
userId: 6,
user: {
id: 6,
name: "User2 name",
email: "email2.com",
avatar: "none",
username: "User2",
},
count: 8,
},
],
admin: { email: "admin.com", name: "admin" },
team: { name: "Team1", id: 4 },
})
);
res.end();
};
export default handler;

View File

@@ -0,0 +1,39 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FUTURE_ROUTES_OVERRIDE_COOKIE_NAME as COOKIE_NAME } from "@calcom/lib/constants";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const session = await getServerSession({ req, res });
if (!session || !session.user || !session.user.email) {
res.status(401).json({ message: "Not authenticated" });
return;
}
let redirectUrl = "/";
// We take you back where you came from if possible
if (typeof req.headers["referer"] === "string") redirectUrl = req.headers["referer"];
/* Only admins can opt-in to future routes for now */
if (session.user.role !== "ADMIN") {
res.redirect(redirectUrl);
return;
}
// If has the cookie, Opt-out of V2
if (COOKIE_NAME in req.cookies && req.cookies[COOKIE_NAME] === "1") {
res.setHeader("Set-Cookie", `${COOKIE_NAME}=0; Max-Age=0; Path=/`);
} else {
/* Opt-int to V2 */
res.setHeader("Set-Cookie", `${COOKIE_NAME}=1; Path=/`);
}
res.redirect(redirectUrl);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,112 @@
import advancedFormat from "dayjs/plugin/advancedFormat";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { fetcher } from "@calcom/lib/retellAIFetcher";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { getRetellLLMSchema } from "@calcom/prisma/zod-utils";
import type { TGetRetellLLMSchema } from "@calcom/prisma/zod-utils";
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
dayjs.extend(advancedFormat);
const schema = z.object({
llm_id: z.string(),
from_number: z.string(),
to_number: z.string(),
});
const getEventTypeIdFromRetellLLM = (
retellLLM: TGetRetellLLMSchema
): { eventTypeId: number | undefined; timezone: string | undefined } => {
const { general_tools, states } = retellLLM;
const generalTool = general_tools.find((tool) => tool.event_type_id && tool.timezone);
if (generalTool) {
return { eventTypeId: generalTool.event_type_id, timezone: generalTool.timezone };
}
// If no general tool found, search in states
if (states) {
for (const state of states) {
const tool = state.tools.find((tool) => tool.event_type_id && tool.timezone);
if (tool) {
return { eventTypeId: tool.event_type_id, timezone: tool.timezone };
}
}
}
return { eventTypeId: undefined, timezone: undefined };
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
const response = schema.safeParse(req.body);
if (!response.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const body = response.data;
const retellLLM = await fetcher(`/get-retell-llm/${body.llm_id}`).then(getRetellLLMSchema.parse);
const { eventTypeId, timezone } = getEventTypeIdFromRetellLLM(retellLLM);
if (!eventTypeId || !timezone)
return res.status(404).json({ message: "eventTypeId or Timezone not found" });
const eventType = await prisma.eventType.findUnique({
where: {
id: eventTypeId,
},
select: {
id: true,
teamId: true,
team: {
select: {
parent: {
select: {
slug: true,
},
},
},
},
},
});
if (!eventType) return res.status(404).json({ message: "eventType not found id" });
const now = dayjs();
const startTime = now.startOf("month").toISOString();
const endTime = now.add(2, "month").endOf("month").toISOString();
const orgSlug = eventType?.team?.parent?.slug ?? undefined;
const availableSlots = await getAvailableSlots({
input: {
startTime,
endTime,
eventTypeId,
isTeamEvent: !!eventType?.teamId,
orgSlug,
},
});
const firstAvailableDate = Object.keys(availableSlots.slots)[0];
const firstSlot = availableSlots?.slots?.[firstAvailableDate]?.[0]?.time;
return res.status(200).json({
next_available: firstSlot
? dayjs.utc(firstSlot).tz(timezone).format(`dddd [the] Do [at] h:mma [${timezone} timezone]`)
: undefined,
});
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,88 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import type { AppDeclarativeHandler, AppHandler } from "@calcom/types/AppHandler";
const defaultIntegrationAddHandler = async ({
slug,
supportsMultipleInstalls,
appType,
user,
teamId = undefined,
createCredential,
}: {
slug: string;
supportsMultipleInstalls: boolean;
appType: string;
user?: Session["user"];
teamId?: number;
createCredential: AppDeclarativeHandler["createCredential"];
}) => {
if (!user?.id) {
throw new HttpError({ statusCode: 401, message: "You must be logged in to do this" });
}
if (!supportsMultipleInstalls) {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
appId: slug,
...(teamId ? { AND: [{ userId: user.id }, { teamId }] } : { userId: user.id }),
},
});
if (alreadyInstalled) {
throw new Error("App is already installed");
}
}
await throwIfNotHaveAdminAccessToTeam({ teamId: teamId ?? null, userId: user.id });
await createCredential({ user: user, appType, slug, teamId });
};
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Check that user is authenticated
req.session = await getServerSession({ req, res });
const { args, teamId } = req.query;
if (!Array.isArray(args)) {
return res.status(404).json({ message: `API route not found` });
}
const [appName, apiEndpoint] = args;
try {
/* Absolute path didn't work */
const handlerMap = (await import("@calcom/app-store/apps.server.generated")).apiHandlers;
const handlerKey = deriveAppDictKeyFromType(appName, handlerMap);
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
if (typeof handler === "undefined")
throw new HttpError({ statusCode: 404, message: `API handler not found` });
if (typeof handler === "function") {
await handler(req, res);
} else {
await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler });
const redirectUrl = handler.redirect?.url ?? undefined;
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
if (!res.writableEnded) return res.status(200);
return res;
} catch (error) {
console.error(error);
if (error instanceof HttpError) {
return res.status(error.statusCode).json({ message: error.message });
}
if (error instanceof Error) {
return res.status(400).json({ message: error.message });
}
return res.status(404).json({ message: `API handler not found` });
}
};
export default handler;

View File

@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/alby/api/webhook";

View File

@@ -0,0 +1 @@
export { default, config } from "@calcom/app-store/paypal/api/webhook";

View File

@@ -0,0 +1 @@
export { default, config } from "@calcom/features/ee/payments/api/webhook";

View File

@@ -0,0 +1,116 @@
import { buffer } from "micro";
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { HttpError as HttpCode } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
// This file is a catch-all for any integration related subscription/paid app.
const handleSubscriptionUpdate = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: subscription.status,
},
});
};
const handleSubscriptionDeleted = async (event: Stripe.Event) => {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.id) throw new HttpCode({ statusCode: 400, message: "Subscription ID not found" });
const app = await prisma.credential.findFirst({
where: {
subscriptionId: subscription.id,
},
});
if (!app) {
throw new HttpCode({ statusCode: 202, message: "Received and discarded" });
}
// should we delete the credential here rather than marking as inactive?
await prisma.credential.update({
where: {
id: app.id,
},
data: {
paymentStatus: "inactive",
billingCycleStart: null,
},
});
};
type WebhookHandler = (event: Stripe.Event) => Promise<void>;
const webhookHandlers: Record<string, WebhookHandler | undefined> = {
"customer.subscription.updated": handleSubscriptionUpdate,
"customer.subscription.deleted": handleSubscriptionDeleted,
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (req.method !== "POST") {
throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" });
}
const sig = req.headers["stripe-signature"];
if (!sig) {
throw new HttpCode({ statusCode: 400, message: "Missing stripe-signature" });
}
if (!process.env.STRIPE_WEBHOOK_SECRET_APPS) {
throw new HttpCode({ statusCode: 500, message: "Missing process.env.STRIPE_WEBHOOK_SECRET_APPS" });
}
const requestBuffer = await buffer(req);
const payload = requestBuffer.toString();
const event = stripe.webhooks.constructEvent(payload, sig, process.env.STRIPE_WEBHOOK_SECRET_APPS);
const handler = webhookHandlers[event.type];
if (handler) {
await handler(event);
} else {
/** Not really an error, just letting Stripe know that the webhook was received but unhandled */
throw new HttpCode({
statusCode: 202,
message: `Unhandled Stripe Webhook event type ${event.type}`,
});
}
} catch (_err) {
const err = getErrorFromUnknown(_err);
console.error(`Webhook Error: ${err.message}`);
res.status(err.statusCode ?? 500).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
return;
}
// Return a response to acknowledge receipt of the event
res.json({ received: true });
}

View File

@@ -0,0 +1,28 @@
import type { NextApiRequest, NextApiResponse } from "next";
import crypto from "node:crypto";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { defaultHandler } from "@calcom/lib/server";
async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
const secret = process.env.INTERCOM_SECRET;
if (!session) {
return res.status(401).json({ message: "user not authenticated" });
}
if (!secret) {
return res.status(400).json({ message: "Intercom Identity Verification secret not set" });
}
const hmac = crypto.createHmac("sha256", secret);
hmac.update(String(session.user.id));
const hash = hmac.digest("hex");
return res.status(200).json({ hash });
}
export default defaultHandler({
GET: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,90 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { TRPCError } from "@calcom/trpc/server";
import { createContext } from "@calcom/trpc/server/createContext";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
import type { UserProfile } from "@calcom/types/UserProfile";
enum DirectAction {
ACCEPT = "accept",
REJECT = "reject",
}
const querySchema = z.object({
action: z.nativeEnum(DirectAction),
token: z.string(),
reason: z.string().optional(),
});
const decryptedSchema = z.object({
bookingUid: z.string(),
userId: z.number().int(),
});
async function handler(req: NextApiRequest, res: NextApiResponse<Response>) {
const { action, token, reason } = querySchema.parse(req.query);
const { bookingUid, userId } = decryptedSchema.parse(
JSON.parse(symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || ""))
);
const booking = await prisma.booking.findUniqueOrThrow({
where: { uid: bookingUid },
});
const user = await prisma.user.findUniqueOrThrow({
where: { id: userId },
});
/** We shape the session as required by tRPC router */
async function sessionGetter() {
return {
user: {
id: userId,
username: "" /* Not used in this context */,
role: UserPermissionRole.USER,
/* Not used in this context */
profile: {
id: null,
organizationId: null,
organization: null,
username: "",
upId: "",
} satisfies UserProfile,
},
upId: "",
hasValidLicense: true,
expires: "" /* Not used in this context */,
};
}
try {
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res }, sessionGetter);
const caller = bookingsRouter.createCaller({
...ctx,
req,
res,
user: { ...user, locale: user?.locale ?? "en" },
});
await caller.confirm({
bookingId: booking.id,
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
});
} catch (e) {
let message = "Error confirming booking";
if (e instanceof TRPCError) message = (e as TRPCError).message;
res.redirect(`/booking/${bookingUid}?error=${encodeURIComponent(message)}`);
return;
}
res.redirect(`/booking/${bookingUid}`);
}
export default defaultResponder(handler);

View File

@@ -0,0 +1,196 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import {
ANDROID_CHROME_ICON_192,
ANDROID_CHROME_ICON_256,
APPLE_TOUCH_ICON,
FAVICON_16,
FAVICON_32,
IS_SELF_HOSTED,
LOGO,
LOGO_ICON,
MSTILE_ICON,
WEBAPP_URL,
} from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
function removePort(url: string) {
return url.replace(/:\d+$/, "");
}
function extractSubdomainAndDomain(hostname: string) {
const hostParts = removePort(hostname).split(".");
const subdomainParts = hostParts.slice(0, hostParts.length - 2);
const domain = hostParts.slice(hostParts.length - 2).join(".");
return [subdomainParts[0], domain];
}
const logoApiSchema = z.object({
type: z.coerce.string().optional(),
});
const SYSTEM_SUBDOMAINS = ["console", "app", "www"];
type LogoType =
| "logo"
| "icon"
| "favicon-16"
| "favicon-32"
| "apple-touch-icon"
| "mstile"
| "android-chrome-192"
| "android-chrome-256";
type LogoTypeDefinition = {
fallback: string;
w?: number;
h?: number;
source: "appLogo" | "appIconLogo";
};
const logoDefinitions: Record<LogoType, LogoTypeDefinition> = {
logo: {
fallback: `${WEBAPP_URL}${LOGO}`,
source: "appLogo",
},
icon: {
fallback: `${WEBAPP_URL}${LOGO_ICON}`,
source: "appIconLogo",
},
"favicon-16": {
fallback: `${WEBAPP_URL}${FAVICON_16}`,
w: 16,
h: 16,
source: "appIconLogo",
},
"favicon-32": {
fallback: `${WEBAPP_URL}${FAVICON_32}`,
w: 32,
h: 32,
source: "appIconLogo",
},
"apple-touch-icon": {
fallback: `${WEBAPP_URL}${APPLE_TOUCH_ICON}`,
w: 180,
h: 180,
source: "appLogo",
},
mstile: {
fallback: `${WEBAPP_URL}${MSTILE_ICON}`,
w: 150,
h: 150,
source: "appLogo",
},
"android-chrome-192": {
fallback: `${WEBAPP_URL}${ANDROID_CHROME_ICON_192}`,
w: 192,
h: 192,
source: "appLogo",
},
"android-chrome-256": {
fallback: `${WEBAPP_URL}${ANDROID_CHROME_ICON_256}`,
w: 256,
h: 256,
source: "appLogo",
},
};
function isValidLogoType(type: string): type is LogoType {
return type in logoDefinitions;
}
async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
try {
if (
// if not cal.com
IS_SELF_HOSTED ||
// missing subdomain (empty string)
!subdomain ||
// in SYSTEM_SUBDOMAINS list
SYSTEM_SUBDOMAINS.includes(subdomain)
) {
throw new Error("No custom logo needed");
}
// load from DB
const { default: prisma } = await import("@calcom/prisma");
const team = await prisma.team.findFirst({
where: {
slug: subdomain,
...(isValidOrgDomain && {
metadata: {
path: ["isOrganization"],
equals: true,
},
}),
},
select: {
appLogo: true,
appIconLogo: true,
},
});
return {
appLogo: team?.appLogo,
appIconLogo: team?.appIconLogo,
};
} catch (error) {
if (error instanceof Error) log.debug(error.message);
return {
appLogo: undefined,
appIconLogo: undefined,
};
}
}
/**
* This API endpoint is used to serve the logo associated with a team if no logo is found we serve our default logo
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { query } = req;
const parsedQuery = logoApiSchema.parse(query);
const { isValidOrgDomain } = orgDomainConfig(req);
const hostname = req?.headers["host"];
if (!hostname) throw new Error("No hostname");
const domains = extractSubdomainAndDomain(hostname);
if (!domains) throw new Error("No domains");
const [subdomain] = domains;
const teamLogos = await getTeamLogos(subdomain, isValidOrgDomain);
// Resolve all icon types to team logos, falling back to Cal.com defaults.
const type: LogoType = parsedQuery?.type && isValidLogoType(parsedQuery.type) ? parsedQuery.type : "logo";
const logoDefinition = logoDefinitions[type];
const filteredLogo = teamLogos[logoDefinition.source] ?? logoDefinition.fallback;
try {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer = Buffer.from(arrayBuffer);
// If we need to resize the team logos (via Next.js' built-in image processing)
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
buffer,
contentType: detectContentType(buffer) ?? "image/jpeg",
quality: 100,
width: logoDefinition.w,
height: logoDefinition.h, // optional
});
}
res.setHeader("Content-Type", response.headers.get("content-type") as string);
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
res.send(buffer);
} catch (error) {
res.statusCode = 404;
res.json({ error: "Failed fetching logo" });
}
}

View File

@@ -0,0 +1,33 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { performance } from "@calcom/lib/server/perfObserver";
let isCold = true;
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
const prePrismaDate = performance.now();
const prisma = (await import("@calcom/prisma")).default;
const preSessionDate = performance.now();
const session = await getServerSession({ req, res });
if (!session) return res.status(409).json({ message: "Unauthorized" });
const preUserDate = performance.now();
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) return res.status(404).json({ message: "No user found" });
const lastUpdate = performance.now();
res.setHeader("x-is-cold", isCold.toString());
isCold = false;
return res.status(200).json({
message: `Hello ${user.name}`,
prePrismaDate,
prismaDuration: `Prisma took ${preSessionDate - prePrismaDate}ms`,
preSessionDate,
sessionDuration: `Session took ${preUserDate - preSessionDate}ms`,
preUserDate,
userDuration: `User took ${lastUpdate - preUserDate}ms`,
lastUpdate,
wasCold: isCold,
});
}

View File

@@ -0,0 +1,9 @@
import type { NextApiRequest, NextApiResponse } from "next";
type Response = {
message: string;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
return res.status(400).json({ message: "Please don't" });
}

View File

@@ -0,0 +1,76 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveTeamToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveTeamToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
log.debug(
"Moving team to org:",
safeStringify({
body: rawBody,
})
);
const translate = await getTranslation("en", "common");
const moveTeamToOrgSchema = getFormSchema(translate);
const parsedBody = moveTeamToOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req, res });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("moveTeamToOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId, moveMembers, teamSlugInOrganization } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await moveTeamToOrg({
targetOrg: {
id: targetOrgId,
teamSlug: teamSlugInOrganization,
},
teamId,
moveMembers,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("moveTeamToOrg failed:", safeStringify(error.message));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("moveTeamToOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({
message: `Added team ${teamId} to Org: ${targetOrgId} ${
moveMembers ? " along with the members" : " without the members"
}`,
});
}

View File

@@ -0,0 +1,75 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveUserToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveUserToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const migrateBodySchema = getFormSchema(translate);
log.debug(
"Starting migration:",
safeStringify({
body: rawBody,
})
);
const parsedBody = migrateBodySchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
res.status(403).json({ message: "No session found" });
return;
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (parsedBody.success) {
const { userId, userName, shouldMoveTeams, targetOrgId, targetOrgUsername, targetOrgRole } =
parsedBody.data;
const isAllowed = isAdmin;
if (isAllowed) {
try {
await moveUserToOrg({
targetOrg: {
id: targetOrgId,
username: targetOrgUsername,
membership: {
role: targetOrgRole,
},
},
user: {
id: userId,
userName,
},
shouldMoveTeams,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(400).json({ message: errorMessage });
}
} else {
return res.status(403).json({ message: "Not Authorized" });
}
return res.status(200).json({ message: "Migrated" });
}
log.error("Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}

View File

@@ -0,0 +1,63 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeTeamFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeTeamFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeTeamFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const removeTeamFromOrgSchema = getFormSchema(translate);
log.debug(
"Removing team from org:",
safeStringify({
body: rawBody,
})
);
const parsedBody = removeTeamFromOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("RemoveTeamFromOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await removeTeamFromOrg({
targetOrgId,
teamId,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: `Removed team ${teamId} from ${targetOrgId}` });
}

View File

@@ -0,0 +1,59 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeUserFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeUserFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeUserFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
log.debug(
"Starting reverse migration:",
safeStringify({
body,
})
);
const translate = await getTranslation("en", "common");
const migrateRevertBodySchema = getFormSchema(translate);
const parsedBody = migrateRevertBodySchema.safeParse(body);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return res.status(403).json({ message: "Only admin can take this action" });
}
if (parsedBody.success) {
const { userId, targetOrgId } = parsedBody.data;
try {
await removeUserFromOrg({ targetOrgId, userId });
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Reverse migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Reverse migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: "Reverted" });
}
log.error("Reverse Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/organizations/api/subteams";

View File

@@ -0,0 +1,231 @@
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import {
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
} from "@calcom/core/videoClient";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { defaultHandler } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
import { getCalendarEvent } from "@calcom/web/lib/daily-webhook/getCalendarEvent";
import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
triggerRecordingReadyWebhook,
triggerTranscriptionGeneratedWebhook,
} from "@calcom/web/lib/daily-webhook/triggerWebhooks";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
const computeSignature = (
hmacSecret: string,
reqBody: NextApiRequest["body"],
webhookTimestampHeader: string | string[] | undefined
) => {
const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
return computed_signature;
};
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
return downloadLink;
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
}
if (testRequestSchema.safeParse(req.body).success) {
return res.status(200).json({ message: "Test request successful" });
}
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
if (!testMode) {
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return res.status(405).json({ message: "No Daily Webhook Secret" });
}
const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]);
if (req.headers["x-webhook-signature"] !== computed_signature) {
return res.status(403).json({ message: "Signature does not match" });
}
}
log.debug(
"Daily video webhook Request Body:",
safeStringify({
body: req.body,
})
);
try {
if (req.body?.type === "recording.ready-to-download") {
const recordingReadyResponse = recordingReadySchema.safeParse(req.body);
if (!recordingReadyResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { room_name, recording_id, status } = recordingReadyResponse.data.payload;
if (status !== "finished") {
return res.status(400).send({
message: "Recording not finished",
});
}
const bookingReference = await getBookingReference(room_name);
const booking = await getBooking(bookingReference.bookingId as number);
const evt = await getCalendarEvent(booking);
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
isRecorded: true,
},
});
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
await triggerRecordingReadyWebhook({
evt,
downloadLink,
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
try {
// Submit Transcription Batch Processor Job
await submitBatchProcessorTranscriptionJob(recording_id);
} catch (err) {
log.error("Failed to Submit Transcription Batch Processor Job:", safeStringify(err));
}
// send emails to all attendees only when user has team plan
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
} else if (req.body.type === "meeting.ended") {
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
if (!meetingEndedResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { room } = meetingEndedResponse.data.payload;
const bookingReference = await getBookingReference(room);
const booking = await getBooking(bookingReference.bookingId as number);
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
if (!transcripts || !transcripts.length)
return res.status(200).json({ message: `No Transcripts found for room name ${room}` });
const evt = await getCalendarEvent(booking);
await sendDailyVideoTranscriptEmails(evt, transcripts);
return res.status(200).json({ message: "Success" });
} else if (req.body?.type === "batch-processor.job-finished") {
const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(req.body);
if (!batchProcessorJobFinishedResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { id, input } = batchProcessorJobFinishedResponse.data.payload;
const roomName = await getRoomNameFromRecordingId(input.recordingId);
const bookingReference = await getBookingReference(roomName);
const booking = await getBooking(bookingReference.bookingId as number);
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
evt,
downloadLinks: {
transcription: batchProcessorJobAccessLink.transcription,
recording,
},
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
return res.status(200).json({ message: "Success" });
} else {
log.error("Invalid type in /recorded-daily-video", req.body);
return res.status(200).json({ message: "Invalid type in /recorded-daily-video" });
}
} catch (err) {
log.error("Error in /recorded-daily-video", err);
if (err instanceof HttpError) {
return res.status(err.statusCode).json({ message: err.message });
} else {
return res.status(500).json({ message: "something went wrong" });
}
}
}
export default defaultHandler({
POST: Promise.resolve({ default: handler }),
});

View File

@@ -0,0 +1,73 @@
import type { DirectorySyncEvent, DirectorySyncRequest } from "@boxyhq/saml-jackson";
import type { NextApiRequest, NextApiResponse } from "next";
import handleGroupEvents from "@calcom/features/ee/dsync/lib/handleGroupEvents";
import handleUserEvents from "@calcom/features/ee/dsync/lib/handleUserEvents";
import jackson from "@calcom/features/ee/sso/lib/jackson";
import prisma from "@calcom/prisma";
// This is the handler for the SCIM API requests
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { dsyncController } = await jackson();
const { method, query, body } = req;
const [directoryId, path, resourceId] = query.directory as string[];
// Handle the SCIM API requests
const request: DirectorySyncRequest = {
method: method as string,
directoryId,
resourceId,
apiSecret: extractAuthToken(req),
resourceType: path === "Users" ? "users" : "groups",
body: body ? JSON.parse(body) : undefined,
query: {
count: req.query.count ? parseInt(req.query.count as string) : undefined,
startIndex: req.query.startIndex ? parseInt(req.query.startIndex as string) : undefined,
filter: req.query.filter as string,
},
};
const { status, data } = await dsyncController.requests.handle(request, handleEvents);
res.status(status).json(data);
}
// Fetch the auth token from the request headers
export const extractAuthToken = (req: NextApiRequest): string | null => {
const authHeader = req.headers.authorization || null;
return authHeader ? authHeader.split(" ")[1] : null;
};
// Handle the SCIM events
const handleEvents = async (event: DirectorySyncEvent) => {
const dSyncData = await prisma.dSyncData.findFirst({
where: {
directoryId: event.directory_id,
},
select: {
id: true,
organizationId: true,
},
});
if (!dSyncData) {
throw new Error("Directory sync data not found");
}
const { organizationId } = dSyncData;
if (!organizationId) {
throw new Error(`Org ID not found for dsync ${dSyncData.id}`);
}
if (event.event.includes("group")) {
handleGroupEvents(event, organizationId);
}
if (event.event === "user.created" || event.event === "user.updated") {
await handleUserEvents(event, organizationId);
}
};

View File

@@ -0,0 +1,121 @@
import { ImageResponse } from "@vercel/og";
import type { NextApiRequest } from "next";
import type { SatoriOptions } from "satori";
import { z } from "zod";
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
const calFont = fetch(new URL("../../../../public/fonts/cal.ttf", import.meta.url)).then((res) =>
res.arrayBuffer()
);
const interFont = fetch(new URL("../../../../public/fonts/Inter-Regular.ttf", import.meta.url)).then((res) =>
res.arrayBuffer()
);
const interFontMedium = fetch(new URL("../../../../public/fonts/Inter-Medium.ttf", import.meta.url)).then(
(res) => res.arrayBuffer()
);
export const config = {
runtime: "edge",
};
const meetingSchema = z.object({
imageType: z.literal("meeting"),
title: z.string(),
names: z.string().array(),
usernames: z.string().array(),
meetingProfileName: z.string(),
meetingImage: z.string().nullable().optional(),
});
const appSchema = z.object({
imageType: z.literal("app"),
name: z.string(),
description: z.string(),
slug: z.string(),
});
const genericSchema = z.object({
imageType: z.literal("generic"),
title: z.string(),
description: z.string(),
});
export default async function handler(req: NextApiRequest) {
const { searchParams } = new URL(`${req.url}`);
const imageType = searchParams.get("type");
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
calFont,
interFont,
interFontMedium,
]);
const ogConfig = {
width: 1200,
height: 630,
fonts: [
{ name: "inter", data: interFontData, weight: 400 },
{ name: "inter", data: interFontMediumData, weight: 500 },
{ name: "cal", data: calFontData, weight: 400 },
{ name: "cal", data: calFontData, weight: 600 },
] as SatoriOptions["fonts"],
};
switch (imageType) {
case "meeting": {
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
names: searchParams.getAll("names"),
usernames: searchParams.getAll("usernames"),
title: searchParams.get("title"),
meetingProfileName: searchParams.get("meetingProfileName"),
meetingImage: searchParams.get("meetingImage"),
imageType,
});
const img = new ImageResponse(
(
<Meeting
title={title}
profile={{ name: meetingProfileName, image: meetingImage }}
users={names.map((name, index) => ({ name, username: usernames[index] }))}
/>
),
ogConfig
) as { body: Buffer };
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
case "app": {
const { name, description, slug } = appSchema.parse({
name: searchParams.get("name"),
description: searchParams.get("description"),
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig) as {
body: Buffer;
};
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
case "generic": {
const { title, description } = genericSchema.parse({
title: searchParams.get("title"),
description: searchParams.get("description"),
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
body: Buffer;
};
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
default:
return new Response("What you're looking for is not here..", { status: 404 });
}
}

View File

@@ -0,0 +1,87 @@
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import getRawBody from "raw-body";
import z from "zod";
import { default as webPrisma } from "@calcom/prisma";
export const config = {
api: {
bodyParser: false,
},
};
const helpscoutRequestBodySchema = z.object({
customer: z.object({
email: z.string().email(),
}),
});
/**
* API for Helpscout to retrieve key information about a user from a ticket
* Note: HelpScout expects a JSON with a `html` prop to show its content as HTML
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") return res.status(405).json({ message: "Method not allowed" });
const hsSignature = req.headers["x-helpscout-signature"];
if (!hsSignature) return res.status(400).end();
if (!process.env.CALENDSO_ENCRYPTION_KEY) return res.status(500).end();
const rawBody = await getRawBody(req);
const parsedBody = helpscoutRequestBodySchema.safeParse(JSON.parse(rawBody.toString()));
if (!parsedBody.success) return res.status(400).end();
const calculatedSig = createHmac("sha1", process.env.CALENDSO_ENCRYPTION_KEY)
.update(rawBody)
.digest("base64");
if (req.headers["x-helpscout-signature"] !== calculatedSig) return res.status(400).end();
const user = await webPrisma.user.findFirst({
where: {
email: parsedBody.data.customer.email,
},
select: {
username: true,
id: true,
createdDate: true,
},
});
if (!user) return res.status(200).json({ html: "User not found" });
const lastBooking = await webPrisma.attendee.findFirst({
where: {
email: parsedBody.data.customer.email,
},
select: {
booking: {
select: {
createdAt: true,
},
},
},
orderBy: {
booking: {
createdAt: "desc",
},
},
});
return res.status(200).json({
html: `
<ul>
<li><b>Username:</b>&nbsp;${user.username}</li>
<li><b>Last booking:</b>&nbsp;${
lastBooking && lastBooking.booking
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
: "No info"
}</li>
<li><b>Account created:</b>&nbsp;${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
</ul>
`,
});
}

View File

@@ -0,0 +1 @@
export { default } from "@calcom/features/ee/teams/api/upgrade";

View File

@@ -0,0 +1,111 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { _MembershipModel as Membership, _TeamModel as Team } from "@calcom/prisma/zod";
const querySchema = z.object({
session_id: z.string().min(1),
});
const checkoutSessionMetadataSchema = z.object({
pendingPaymentTeamId: z.string().transform(Number),
ownerId: z.string().transform(Number),
});
type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>;
export const schemaTeamReadPublic = Team.omit({});
export const schemaMembershipPublic = Membership.merge(z.object({ team: Team }).partial());
async function handler(req: NextApiRequest, res: NextApiResponse) {
const checkoutSession = await getCheckoutSession(req);
validateCheckoutSession(checkoutSession);
const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession);
const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession);
const finalizedTeam = await prisma.team.update({
where: { id: checkoutSessionMetadata.pendingPaymentTeamId },
data: {
pendingPayment: false,
members: {
create: {
userId: checkoutSessionMetadata.ownerId as number,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
paymentId: checkoutSession.id,
subscriptionId: checkoutSessionSubscription.id || null,
subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null,
},
},
include: { members: true },
});
const response = JSON.stringify(
{
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
team: schemaTeamReadPublic.parse(finalizedTeam),
owner: schemaMembershipPublic.parse(finalizedTeam.members[0]),
},
null,
2
);
return res.status(200).send(response);
}
async function getCheckoutSession(req: NextApiRequest) {
const { session_id } = querySchema.parse(req.query);
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["subscription"],
});
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
return checkoutSession;
}
function validateCheckoutSession(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
if (checkoutSession.payment_status !== "paid")
throw new HttpError({ statusCode: 402, message: "Payment required" });
}
function getCheckoutSessionSubscription(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
if (!checkoutSession.subscription) {
throw new HttpError({
statusCode: 400,
message: "Can't publish team/org without subscription",
});
}
return checkoutSession.subscription as Stripe.Subscription;
}
function getCheckoutSessionMetadata(
checkoutSession: Stripe.Response<Stripe.Checkout.Session>
): CheckoutSessionMetadata {
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
if (!parseCheckoutSessionMetadata.success) {
throw new HttpError({
statusCode: 400,
message: `Incorrect metadata in checkout session. Error: ${parseCheckoutSessionMetadata.error}`,
});
}
const checkoutSessionMetadata = parseCheckoutSessionMetadata.data;
return checkoutSessionMetadata;
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,92 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type Stripe from "stripe";
import { z } from "zod";
import stripe from "@calcom/features/ee/payments/server/stripe";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
const querySchema = z.object({
session_id: z.string().min(1),
});
const checkoutSessionMetadataSchema = z.object({
teamName: z.string(),
teamSlug: z.string(),
userId: z.string().transform(Number),
});
const generateRandomString = () => {
return Math.random().toString(36).substring(2, 10);
};
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { session_id } = querySchema.parse(req.query);
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
expand: ["subscription"],
});
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
const subscription = checkoutSession.subscription as Stripe.Subscription;
if (checkoutSession.payment_status !== "paid")
throw new HttpError({ statusCode: 402, message: "Payment required" });
// Let's query to ensure that the team metadata carried over from the checkout session.
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
if (!parseCheckoutSessionMetadata.success) {
console.error(
"Team metadata not found in checkout session",
parseCheckoutSessionMetadata.error,
checkoutSession.id
);
}
if (!checkoutSession.metadata?.userId) {
throw new HttpError({
statusCode: 400,
message: "Can't publish team/org without userId",
});
}
const checkoutSessionMetadata = parseCheckoutSessionMetadata.success
? parseCheckoutSessionMetadata.data
: {
teamName: checkoutSession?.metadata?.teamName ?? generateRandomString(),
teamSlug: checkoutSession?.metadata?.teamSlug ?? generateRandomString(),
userId: checkoutSession.metadata.userId,
};
const team = await prisma.team.create({
data: {
name: checkoutSessionMetadata.teamName,
slug: checkoutSessionMetadata.teamSlug,
members: {
create: {
userId: checkoutSessionMetadata.userId as number,
role: MembershipRole.OWNER,
accepted: true,
},
},
metadata: {
paymentId: checkoutSession.id,
subscriptionId: subscription.id || null,
subscriptionItemId: subscription.items.data[0].id || null,
},
},
});
// Sync Services: Close.com
// closeComUpdateTeam(prevTeam, team);
// redirect to team screen
res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`);
}
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});

View File

@@ -0,0 +1,35 @@
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { WEBAPP_URL } from "@calcom/lib/constants";
const scopes = [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.customer.readonly",
];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Get appKeys from google-calendar
const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar");
if (!client_id || typeof client_id !== "string")
return res.status(400).json({ message: "Google client_id missing." });
if (!client_secret || typeof client_secret !== "string")
return res.status(400).json({ message: "Google client_secret missing." });
// use differnt callback to normal calendar connection
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: scopes,
prompt: "consent",
state: JSON.stringify({ teamId: req.query.teamId }),
});
res.status(200).json({ url: authUrl });
}
}

View File

@@ -0,0 +1,64 @@
import { google } from "googleapis";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import getAppKeysFromSlug from "@calcom/app-store/_utils/getAppKeysFromSlug";
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
const stateSchema = z.object({
teamId: z.string(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
if (!session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const { code, state } = req.query;
const parsedState = stateSchema.parse(JSON.parse(state as string));
const { teamId } = parsedState;
await throwIfNotHaveAdminAccessToTeam({ teamId: Number(teamId) ?? null, userId: session.user.id });
if (code && typeof code !== "string") {
res.status(400).json({ message: "`code` must be a string" });
return;
}
const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar");
if (!client_id || typeof client_id !== "string")
return res.status(400).json({ message: "Google client_id missing." });
if (!client_secret || typeof client_secret !== "string")
return res.status(400).json({ message: "Google client_secret missing." });
const redirect_uri = `${WEBAPP_URL}/api/teams/googleworkspace/callback`;
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
throw new Error("No code provided");
}
const credentials = await oAuth2Client.getToken(code);
await prisma.credential.create({
data: {
type: "google_workspace_directory",
key: credentials.res?.data,
userId: session.user.id,
},
});
if (!teamId) {
res.redirect(getSafeRedirectUrl(`${WEBAPP_URL}/settings`) ?? `${WEBAPP_URL}/teams`);
}
res.redirect(
getSafeRedirectUrl(`${WEBAPP_URL}/settings/teams/${teamId}/members?inviteModal=true&bulk=true`) ??
`${WEBAPP_URL}/teams`
);
}

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { adminRouter } from "@calcom/trpc/server/routers/viewer/admin/_router";
export default createNextApiHandler(adminRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { apiKeysRouter } from "@calcom/trpc/server/routers/viewer/apiKeys/_router";
export default createNextApiHandler(apiKeysRouter);

View File

@@ -0,0 +1,4 @@
import appBasecamp3 from "@calcom/app-store/basecamp3/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(appBasecamp3);

View File

@@ -0,0 +1,4 @@
import appRoutingForms from "@calcom/app-store/routing-forms/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(appRoutingForms);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
export default createNextApiHandler(appsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
export default createNextApiHandler(appsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { authRouter } from "@calcom/trpc/server/routers/viewer/auth/_router";
export default createNextApiHandler(authRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { availabilityRouter } from "@calcom/trpc/server/routers/viewer/availability/_router";
export default createNextApiHandler(availabilityRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { bookingsRouter } from "@calcom/trpc/server/routers/viewer/bookings/_router";
export default createNextApiHandler(bookingsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { deploymentSetupRouter } from "@calcom/trpc/server/routers/viewer/deploymentSetup/_router";
export default createNextApiHandler(deploymentSetupRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { dsyncRouter } from "@calcom/trpc/server/routers/viewer/dsync/_router";
export default createNextApiHandler(dsyncRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { eventTypesRouter } from "@calcom/trpc/server/routers/viewer/eventTypes/_router";
export default createNextApiHandler(eventTypesRouter);

View File

@@ -0,0 +1,4 @@
import { featureFlagRouter } from "@calcom/features/flags/server/router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(featureFlagRouter, true, "features");

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { googleWorkspaceRouter } from "@calcom/trpc/server/routers/viewer/googleWorkspace/_router";
export default createNextApiHandler(googleWorkspaceRouter);

View File

@@ -0,0 +1,4 @@
import { insightsRouter } from "@calcom/features/insights/server/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(insightsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { oAuthRouter } from "@calcom/trpc/server/routers/viewer/oAuth/_router";
export default createNextApiHandler(oAuthRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
export default createNextApiHandler(viewerOrganizationsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { paymentsRouter } from "@calcom/trpc/server/routers/viewer/payments/_router";
export default createNextApiHandler(paymentsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { publicViewerRouter } from "@calcom/trpc/server/routers/publicViewer/_router";
export default createNextApiHandler(publicViewerRouter, true);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { ssoRouter } from "@calcom/trpc/server/routers/viewer/sso/_router";
export default createNextApiHandler(ssoRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
export default createNextApiHandler(slotsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
export default createNextApiHandler(viewerTeamsRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { timezonesRouter } from "@calcom/trpc/server/routers/publicViewer/timezones/_router";
export default createNextApiHandler(timezonesRouter, true);

View File

@@ -0,0 +1,4 @@
import { userAdminRouter } from "@calcom/features/ee/users/server/trpc-router";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
export default createNextApiHandler(userAdminRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { loggedInViewerRouter } from "@calcom/trpc/server/routers/loggedInViewer/_router";
export default createNextApiHandler(loggedInViewerRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
export default createNextApiHandler(webhookRouter);

View File

@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { workflowsRouter } from "@calcom/trpc/server/routers/viewer/workflows/_router";
export default createNextApiHandler(workflowsRouter);

View File

@@ -0,0 +1,22 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import { checkUsername } from "@calcom/lib/server/checkUsername";
type Response = {
available: boolean;
premium: boolean;
};
const bodySchema = z.object({
username: z.string(),
orgSlug: z.string().optional(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
const { currentOrgDomain } = orgDomainConfig(req);
const { username, orgSlug } = bodySchema.parse(req.body);
const result = await checkUsername(username, currentOrgDomain || orgSlug);
return res.status(200).json(result);
}

View File

@@ -0,0 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import * as pjson from "package.json";
type Response = {
version: string;
};
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
return res.status(200).json({ version: pjson.version });
}

View File

@@ -0,0 +1,91 @@
import type { NextApiRequest, NextApiResponse } from "next";
import z from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { CREDENTIAL_SYNC_SECRET, CREDENTIAL_SYNC_SECRET_HEADER_NAME } from "@calcom/lib/constants";
import { APP_CREDENTIAL_SHARING_ENABLED } from "@calcom/lib/constants";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import prisma from "@calcom/prisma";
const appCredentialWebhookRequestBodySchema = z.object({
// UserId of the cal.com user
userId: z.number().int(),
appSlug: z.string(),
// Keys should be AES256 encrypted with the CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY
keys: z.string(),
});
/** */
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!APP_CREDENTIAL_SHARING_ENABLED) {
return res.status(403).json({ message: "Credential sharing is not enabled" });
}
if (req.headers[CREDENTIAL_SYNC_SECRET_HEADER_NAME] !== CREDENTIAL_SYNC_SECRET) {
return res.status(403).json({ message: "Invalid credential sync secret" });
}
const reqBodyParsed = appCredentialWebhookRequestBodySchema.safeParse(req.body);
if (!reqBodyParsed.success) {
return res.status(400).json({ error: reqBodyParsed.error.issues });
}
const reqBody = reqBodyParsed.data;
const user = await prisma.user.findUnique({ where: { id: reqBody.userId } });
if (!user) {
return res.status(404).json({ message: "User not found" });
}
const app = await prisma.app.findUnique({
where: { slug: reqBody.appSlug },
select: { dirName: true },
});
if (!app) {
return res.status(404).json({ message: "App not found" });
}
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
if (!appMetadata) {
return res.status(404).json({ message: "App not found. Ensure that you have the correct app slug" });
}
const keys = JSON.parse(
symmetricDecrypt(reqBody.keys, process.env.CALCOM_APP_CREDENTIAL_ENCRYPTION_KEY || "")
);
// INFO: Can't use prisma upsert as we don't know the id of the credential
const appCredential = await prisma.credential.findFirst({
where: {
userId: reqBody.userId,
appId: appMetadata.slug,
},
select: {
id: true,
},
});
if (appCredential) {
await prisma.credential.update({
where: {
id: appCredential.id,
},
data: {
key: keys,
},
});
return res.status(200).json({ message: `Credentials updated for userId: ${reqBody.userId}` });
} else {
await prisma.credential.create({
data: {
key: keys,
userId: reqBody.userId,
appId: appMetadata.slug,
type: appMetadata.type,
},
});
return res.status(200).json({ message: `Credentials created for userId: ${reqBody.userId}` });
}
}