first commit
This commit is contained in:
7
calcom/apps/web/pages/api/_README/README.md
Normal file
7
calcom/apps/web/pages/api/_README/README.md
Normal 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`
|
||||
5
calcom/apps/web/pages/api/auth/[...nextauth].tsx
Normal file
5
calcom/apps/web/pages/api/auth/[...nextauth].tsx
Normal 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);
|
||||
70
calcom/apps/web/pages/api/auth/changepw.ts
Normal file
70
calcom/apps/web/pages/api/auth/changepw.ts
Normal 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" });
|
||||
}
|
||||
52
calcom/apps/web/pages/api/auth/forgot-password.ts
Normal file
52
calcom/apps/web/pages/api/auth/forgot-password.ts
Normal 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 }),
|
||||
});
|
||||
14
calcom/apps/web/pages/api/auth/oauth/me.ts
Normal file
14
calcom/apps/web/pages/api/auth/oauth/me.ts
Normal 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 });
|
||||
}
|
||||
69
calcom/apps/web/pages/api/auth/oauth/refreshToken.ts
Normal file
69
calcom/apps/web/pages/api/auth/oauth/refreshToken.ts
Normal 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 });
|
||||
}
|
||||
97
calcom/apps/web/pages/api/auth/oauth/token.ts
Normal file
97
calcom/apps/web/pages/api/auth/oauth/token.ts
Normal 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 });
|
||||
}
|
||||
36
calcom/apps/web/pages/api/auth/oidc.ts
Normal file
36
calcom/apps/web/pages/api/auth/oidc.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
75
calcom/apps/web/pages/api/auth/reset-password.ts
Normal file
75
calcom/apps/web/pages/api/auth/reset-password.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
23
calcom/apps/web/pages/api/auth/saml/authorize.ts
Normal file
23
calcom/apps/web/pages/api/auth/saml/authorize.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
calcom/apps/web/pages/api/auth/saml/callback.ts
Normal file
18
calcom/apps/web/pages/api/auth/saml/callback.ts
Normal 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) }),
|
||||
});
|
||||
13
calcom/apps/web/pages/api/auth/saml/token.ts
Normal file
13
calcom/apps/web/pages/api/auth/saml/token.ts
Normal 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) }),
|
||||
});
|
||||
34
calcom/apps/web/pages/api/auth/saml/userinfo.ts
Normal file
34
calcom/apps/web/pages/api/auth/saml/userinfo.ts
Normal 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) }),
|
||||
});
|
||||
58
calcom/apps/web/pages/api/auth/setup.ts
Normal file
58
calcom/apps/web/pages/api/auth/setup.ts
Normal 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) }),
|
||||
});
|
||||
71
calcom/apps/web/pages/api/auth/signup.ts
Normal file
71
calcom/apps/web/pages/api/auth/signup.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
114
calcom/apps/web/pages/api/auth/two-factor/totp/disable.ts
Normal file
114
calcom/apps/web/pages/api/auth/two-factor/totp/disable.ts
Normal 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" });
|
||||
}
|
||||
66
calcom/apps/web/pages/api/auth/two-factor/totp/enable.ts
Normal file
66
calcom/apps/web/pages/api/auth/two-factor/totp/enable.ts
Normal 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" });
|
||||
}
|
||||
79
calcom/apps/web/pages/api/auth/two-factor/totp/setup.ts
Normal file
79
calcom/apps/web/pages/api/auth/two-factor/totp/setup.ts
Normal 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 });
|
||||
}
|
||||
82
calcom/apps/web/pages/api/auth/verify-email.test.ts
Normal file
82
calcom/apps/web/pages/api/auth/verify-email.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
181
calcom/apps/web/pages/api/auth/verify-email.ts
Normal file
181
calcom/apps/web/pages/api/auth/verify-email.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
100
calcom/apps/web/pages/api/availability/calendar.ts
Normal file
100
calcom/apps/web/pages/api/availability/calendar.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
62
calcom/apps/web/pages/api/avatar/[uuid].ts
Normal file
62
calcom/apps/web/pages/api/avatar/[uuid].ts
Normal 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);
|
||||
}
|
||||
24
calcom/apps/web/pages/api/book/event.ts
Normal file
24
calcom/apps/web/pages/api/book/event.ts
Normal 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);
|
||||
22
calcom/apps/web/pages/api/book/instant-event.ts
Normal file
22
calcom/apps/web/pages/api/book/instant-event.ts
Normal 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);
|
||||
30
calcom/apps/web/pages/api/book/recurring-event.ts
Normal file
30
calcom/apps/web/pages/api/book/recurring-event.ts
Normal 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);
|
||||
17
calcom/apps/web/pages/api/cancel.ts
Normal file
17
calcom/apps/web/pages/api/cancel.ts
Normal 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) }),
|
||||
});
|
||||
9
calcom/apps/web/pages/api/collect-events.ts
Normal file
9
calcom/apps/web/pages/api/collect-events.ts
Normal 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,
|
||||
});
|
||||
146
calcom/apps/web/pages/api/cron/bookingReminder.ts
Normal file
146
calcom/apps/web/pages/api/cron/bookingReminder.ts
Normal 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 });
|
||||
}
|
||||
16
calcom/apps/web/pages/api/cron/calendar-cache-cleanup.ts
Normal file
16
calcom/apps/web/pages/api/cron/calendar-cache-cleanup.ts
Normal 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 });
|
||||
}
|
||||
173
calcom/apps/web/pages/api/cron/changeTimeZone.ts
Normal file
173
calcom/apps/web/pages/api/cron/changeTimeZone.ts
Normal 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 });
|
||||
}
|
||||
53
calcom/apps/web/pages/api/cron/downgradeUsers.ts
Normal file
53
calcom/apps/web/pages/api/cron/downgradeUsers.ts
Normal 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 });
|
||||
}
|
||||
315
calcom/apps/web/pages/api/cron/monthlyDigestEmail.ts
Normal file
315
calcom/apps/web/pages/api/cron/monthlyDigestEmail.ts
Normal 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 });
|
||||
}
|
||||
67
calcom/apps/web/pages/api/cron/syncAppMeta.ts
Normal file
67
calcom/apps/web/pages/api/cron/syncAppMeta.ts
Normal 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 });
|
||||
}
|
||||
1
calcom/apps/web/pages/api/cron/webhookTriggers.ts
Normal file
1
calcom/apps/web/pages/api/cron/webhookTriggers.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/webhooks/lib/cron";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/api/scheduleEmailReminders";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/api/scheduleSMSReminders";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/workflows/api/scheduleWhatsappReminders";
|
||||
66
calcom/apps/web/pages/api/email.ts
Normal file
66
calcom/apps/web/pages/api/email.ts
Normal 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;
|
||||
39
calcom/apps/web/pages/api/future-opt-in.ts
Normal file
39
calcom/apps/web/pages/api/future-opt-in.ts
Normal 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) }),
|
||||
});
|
||||
112
calcom/apps/web/pages/api/get-inbound-dynamic-variables.ts
Normal file
112
calcom/apps/web/pages/api/get-inbound-dynamic-variables.ts
Normal 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 }),
|
||||
});
|
||||
88
calcom/apps/web/pages/api/integrations/[...args].ts
Normal file
88
calcom/apps/web/pages/api/integrations/[...args].ts
Normal 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;
|
||||
1
calcom/apps/web/pages/api/integrations/alby/webhook.ts
Normal file
1
calcom/apps/web/pages/api/integrations/alby/webhook.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/app-store/alby/api/webhook";
|
||||
1
calcom/apps/web/pages/api/integrations/paypal/webhook.ts
Normal file
1
calcom/apps/web/pages/api/integrations/paypal/webhook.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/app-store/paypal/api/webhook";
|
||||
@@ -0,0 +1 @@
|
||||
export { default, config } from "@calcom/features/ee/payments/api/webhook";
|
||||
116
calcom/apps/web/pages/api/integrations/subscriptions/webhook.ts
Normal file
116
calcom/apps/web/pages/api/integrations/subscriptions/webhook.ts
Normal 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 });
|
||||
}
|
||||
28
calcom/apps/web/pages/api/intercom-hash.ts
Normal file
28
calcom/apps/web/pages/api/intercom-hash.ts
Normal 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 }),
|
||||
});
|
||||
90
calcom/apps/web/pages/api/link.ts
Normal file
90
calcom/apps/web/pages/api/link.ts
Normal 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);
|
||||
196
calcom/apps/web/pages/api/logo.ts
Normal file
196
calcom/apps/web/pages/api/logo.ts
Normal 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" });
|
||||
}
|
||||
}
|
||||
33
calcom/apps/web/pages/api/me.ts
Normal file
33
calcom/apps/web/pages/api/me.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
9
calcom/apps/web/pages/api/nope.ts
Normal file
9
calcom/apps/web/pages/api/nope.ts
Normal 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" });
|
||||
}
|
||||
76
calcom/apps/web/pages/api/orgMigration/moveTeamToOrg.ts
Normal file
76
calcom/apps/web/pages/api/orgMigration/moveTeamToOrg.ts
Normal 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"
|
||||
}`,
|
||||
});
|
||||
}
|
||||
75
calcom/apps/web/pages/api/orgMigration/moveUserToOrg.ts
Normal file
75
calcom/apps/web/pages/api/orgMigration/moveUserToOrg.ts
Normal 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) });
|
||||
}
|
||||
63
calcom/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts
Normal file
63
calcom/apps/web/pages/api/orgMigration/removeTeamFromOrg.ts
Normal 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}` });
|
||||
}
|
||||
59
calcom/apps/web/pages/api/orgMigration/removeUserFromOrg.ts
Normal file
59
calcom/apps/web/pages/api/orgMigration/removeUserFromOrg.ts
Normal 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) });
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/organizations/api/subteams";
|
||||
231
calcom/apps/web/pages/api/recorded-daily-video.ts
Normal file
231
calcom/apps/web/pages/api/recorded-daily-video.ts
Normal 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 }),
|
||||
});
|
||||
73
calcom/apps/web/pages/api/scim/v2.0/[...directory].ts
Normal file
73
calcom/apps/web/pages/api/scim/v2.0/[...directory].ts
Normal 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);
|
||||
}
|
||||
};
|
||||
121
calcom/apps/web/pages/api/social/og/image.tsx
Normal file
121
calcom/apps/web/pages/api/social/og/image.tsx
Normal 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 });
|
||||
}
|
||||
}
|
||||
87
calcom/apps/web/pages/api/sync/helpscout/index.ts
Normal file
87
calcom/apps/web/pages/api/sync/helpscout/index.ts
Normal 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> ${user.username}</li>
|
||||
<li><b>Last booking:</b> ${
|
||||
lastBooking && lastBooking.booking
|
||||
? new Date(lastBooking.booking.createdAt).toLocaleDateString("en-US")
|
||||
: "No info"
|
||||
}</li>
|
||||
<li><b>Account created:</b> ${new Date(user.createdDate).toLocaleDateString("en-US")}</li>
|
||||
</ul>
|
||||
`,
|
||||
});
|
||||
}
|
||||
1
calcom/apps/web/pages/api/teams/[team]/upgrade.ts
Normal file
1
calcom/apps/web/pages/api/teams/[team]/upgrade.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@calcom/features/ee/teams/api/upgrade";
|
||||
111
calcom/apps/web/pages/api/teams/api/create.ts
Normal file
111
calcom/apps/web/pages/api/teams/api/create.ts
Normal 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) }),
|
||||
});
|
||||
92
calcom/apps/web/pages/api/teams/create.ts
Normal file
92
calcom/apps/web/pages/api/teams/create.ts
Normal 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) }),
|
||||
});
|
||||
35
calcom/apps/web/pages/api/teams/googleworkspace/add.ts
Normal file
35
calcom/apps/web/pages/api/teams/googleworkspace/add.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
64
calcom/apps/web/pages/api/teams/googleworkspace/callback.ts
Normal file
64
calcom/apps/web/pages/api/teams/googleworkspace/callback.ts
Normal 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`
|
||||
);
|
||||
}
|
||||
4
calcom/apps/web/pages/api/trpc/admin/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/admin/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/apiKeys/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/apiKeys/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/appBasecamp3/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/appBasecamp3/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/appRoutingForms/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/appRoutingForms/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/apps/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/apps/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/appsRouter/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/appsRouter/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/auth/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/auth/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/availability/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/availability/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/bookings/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/bookings/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/deploymentSetup/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/deploymentSetup/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/dsync/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/dsync/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/eventTypes/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/eventTypes/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/features/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/features/[trpc].ts
Normal 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");
|
||||
4
calcom/apps/web/pages/api/trpc/googleWorkspace/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/googleWorkspace/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/insights/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/insights/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/oAuth/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/oAuth/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/organizations/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/organizations/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/payments/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/payments/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/public/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/public/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/saml/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/saml/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/slots/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/slots/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/teams/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/teams/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/timezones/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/timezones/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/users/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/users/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/viewer/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/viewer/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/webhook/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/webhook/[trpc].ts
Normal 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);
|
||||
4
calcom/apps/web/pages/api/trpc/workflows/[trpc].ts
Normal file
4
calcom/apps/web/pages/api/trpc/workflows/[trpc].ts
Normal 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);
|
||||
22
calcom/apps/web/pages/api/username.ts
Normal file
22
calcom/apps/web/pages/api/username.ts
Normal 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);
|
||||
}
|
||||
10
calcom/apps/web/pages/api/version.ts
Normal file
10
calcom/apps/web/pages/api/version.ts
Normal 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 });
|
||||
}
|
||||
91
calcom/apps/web/pages/api/webhook/app-credential.ts
Normal file
91
calcom/apps/web/pages/api/webhook/app-credential.ts
Normal 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}` });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user