first commit
This commit is contained in:
1
calcom/packages/trpc/client/index.ts
Normal file
1
calcom/packages/trpc/client/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@trpc/client";
|
||||
4
calcom/packages/trpc/index.ts
Normal file
4
calcom/packages/trpc/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./client";
|
||||
export * from "./next";
|
||||
export * from "./react";
|
||||
export * from "./server";
|
||||
1
calcom/packages/trpc/next/index.ts
Normal file
1
calcom/packages/trpc/next/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@trpc/next";
|
||||
22
calcom/packages/trpc/package.json
Normal file
22
calcom/packages/trpc/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@calcom/trpc",
|
||||
"sideEffects": false,
|
||||
"private": true,
|
||||
"description": "Shared tRPC library for Cal.com",
|
||||
"authors": "zomars",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@trpc/client": "11.0.0-next-beta.222",
|
||||
"@trpc/next": "11.0.0-next-beta.222",
|
||||
"@trpc/react-query": "11.0.0-next-beta.222",
|
||||
"@trpc/server": "11.0.0-next-beta.222",
|
||||
"superjson": "1.9.1",
|
||||
"zod": "^3.22.4"
|
||||
}
|
||||
}
|
||||
13
calcom/packages/trpc/react/hooks/useEmailVerifyCheck.ts
Normal file
13
calcom/packages/trpc/react/hooks/useEmailVerifyCheck.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { trpc } from "../trpc";
|
||||
|
||||
export function useEmailVerifyCheck() {
|
||||
const emailCheck = trpc.viewer.shouldVerifyEmail.useQuery(undefined, {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return emailCheck;
|
||||
}
|
||||
|
||||
export default useEmailVerifyCheck;
|
||||
13
calcom/packages/trpc/react/hooks/useMeQuery.ts
Normal file
13
calcom/packages/trpc/react/hooks/useMeQuery.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { trpc } from "../trpc";
|
||||
|
||||
export function useMeQuery() {
|
||||
const meQuery = trpc.viewer.me.useQuery(undefined, {
|
||||
retry(failureCount) {
|
||||
return failureCount > 3;
|
||||
},
|
||||
});
|
||||
|
||||
return meQuery;
|
||||
}
|
||||
|
||||
export default useMeQuery;
|
||||
2
calcom/packages/trpc/react/index.ts
Normal file
2
calcom/packages/trpc/react/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "@trpc/react-query";
|
||||
export * from "./trpc";
|
||||
1
calcom/packages/trpc/react/server.ts
Normal file
1
calcom/packages/trpc/react/server.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@trpc/react-query/server";
|
||||
31
calcom/packages/trpc/react/shared.ts
Normal file
31
calcom/packages/trpc/react/shared.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export * from "@trpc/react-query/shared";
|
||||
|
||||
export const ENDPOINTS = [
|
||||
"admin",
|
||||
"apiKeys",
|
||||
"appRoutingForms",
|
||||
"apps",
|
||||
"auth",
|
||||
"availability",
|
||||
"appBasecamp3",
|
||||
"bookings",
|
||||
"deploymentSetup",
|
||||
"dsync",
|
||||
"eventTypes",
|
||||
"features",
|
||||
"insights",
|
||||
"payments",
|
||||
"public",
|
||||
"timezones",
|
||||
"saml",
|
||||
"slots",
|
||||
"teams",
|
||||
"organizations",
|
||||
"users",
|
||||
"viewer",
|
||||
"webhook",
|
||||
"workflows",
|
||||
"appsRouter",
|
||||
"googleWorkspace",
|
||||
"oAuth",
|
||||
] as const;
|
||||
139
calcom/packages/trpc/react/trpc.ts
Normal file
139
calcom/packages/trpc/react/trpc.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { NextPageContext } from "next/types";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { httpBatchLink } from "../client";
|
||||
import { httpLink } from "../client";
|
||||
import { loggerLink } from "../client";
|
||||
import { splitLink } from "../client";
|
||||
import type { CreateTRPCNext } from "../next";
|
||||
import { createTRPCNext } from "../next";
|
||||
// ℹ️ Type-only import:
|
||||
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export
|
||||
import type { TRPCClientErrorLike } from "../react";
|
||||
import type { inferRouterInputs, inferRouterOutputs } from "../server";
|
||||
import type { AppRouter } from "../server/routers/_app";
|
||||
import { ENDPOINTS } from "./shared";
|
||||
|
||||
type Maybe<T> = T | null | undefined;
|
||||
|
||||
/**
|
||||
* We deploy our tRPC router on multiple lambdas to keep number of imports as small as possible
|
||||
* TODO: Make this dynamic based on folders in trpc server?
|
||||
*/
|
||||
export type Endpoint = (typeof ENDPOINTS)[number];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const resolveEndpoint = (links: any) => {
|
||||
// TODO: Update our trpc routes so they are more clear.
|
||||
// This function parses paths like the following and maps them
|
||||
// to the correct API endpoints.
|
||||
// - viewer.me - 2 segment paths like this are for logged in requests
|
||||
// - viewer.public.i18n - 3 segments paths can be public or authed
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (ctx: any) => {
|
||||
const parts = ctx.op.path.split(".");
|
||||
let endpoint;
|
||||
let path = "";
|
||||
if (parts.length == 2) {
|
||||
endpoint = parts[0] as keyof typeof links;
|
||||
path = parts[1];
|
||||
} else {
|
||||
endpoint = parts[1] as keyof typeof links;
|
||||
path = parts.splice(2, parts.length - 2).join(".");
|
||||
}
|
||||
return links[endpoint]({ ...ctx, op: { ...ctx.op, path } });
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A set of strongly-typed React hooks from your `AppRouter` type signature with `createTRPCReact`.
|
||||
* @link https://trpc.io/docs/v10/react#2-create-trpc-hooks
|
||||
*/
|
||||
export const trpc: CreateTRPCNext<AppRouter, NextPageContext, null> = createTRPCNext<
|
||||
AppRouter,
|
||||
NextPageContext
|
||||
>({
|
||||
config() {
|
||||
const url =
|
||||
typeof window !== "undefined"
|
||||
? "/api/trpc"
|
||||
: process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}/api/trpc`
|
||||
: `${process.env.NEXT_PUBLIC_WEBAPP_URL}/api/trpc`;
|
||||
|
||||
/**
|
||||
* If you want to use SSR, you need to use the server's full URL
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
return {
|
||||
/**
|
||||
* @link https://trpc.io/docs/links
|
||||
*/
|
||||
links: [
|
||||
// adds pretty logs to your console in development and logs errors in production
|
||||
loggerLink({
|
||||
enabled: (opts) =>
|
||||
!!process.env.NEXT_PUBLIC_DEBUG || (opts.direction === "down" && opts.result instanceof Error),
|
||||
}),
|
||||
splitLink({
|
||||
// check for context property `skipBatch`
|
||||
condition: (op) => !!op.context.skipBatch,
|
||||
// when condition is true, use normal request
|
||||
true: (runtime) => {
|
||||
const links = Object.fromEntries(
|
||||
ENDPOINTS.map((endpoint) => [endpoint, httpLink({ url: `${url}/${endpoint}` })(runtime)])
|
||||
);
|
||||
return resolveEndpoint(links);
|
||||
},
|
||||
// when condition is false, use batch request
|
||||
false: (runtime) => {
|
||||
const links = Object.fromEntries(
|
||||
ENDPOINTS.map((endpoint) => [endpoint, httpBatchLink({ url: `${url}/${endpoint}` })(runtime)])
|
||||
);
|
||||
return resolveEndpoint(links);
|
||||
},
|
||||
}),
|
||||
],
|
||||
/**
|
||||
* @link https://react-query.tanstack.com/reference/QueryClient
|
||||
*/
|
||||
queryClientConfig: {
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
/**
|
||||
* 1s should be enough to just keep identical query waterfalls low
|
||||
* @example if one page components uses a query that is also used further down the tree
|
||||
*/
|
||||
staleTime: 1000,
|
||||
/**
|
||||
* Retry `useQuery()` calls depending on this function
|
||||
*/
|
||||
retry(failureCount, _err) {
|
||||
const err = _err as never as Maybe<TRPCClientErrorLike<AppRouter>>;
|
||||
const code = err?.data?.code;
|
||||
if (code === "BAD_REQUEST" || code === "FORBIDDEN" || code === "UNAUTHORIZED") {
|
||||
// if input data is wrong or you're not authorized there's no point retrying a query
|
||||
return false;
|
||||
}
|
||||
const MAX_QUERY_RETRIES = 3;
|
||||
return failureCount < MAX_QUERY_RETRIES;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/data-transformers
|
||||
*/
|
||||
transformer: superjson,
|
||||
};
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/ssr
|
||||
*/
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export const transformer = superjson;
|
||||
|
||||
export type RouterInputs = inferRouterInputs<AppRouter>;
|
||||
export type RouterOutputs = inferRouterOutputs<AppRouter>;
|
||||
1
calcom/packages/trpc/server/adapters/next.ts
Normal file
1
calcom/packages/trpc/server/adapters/next.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@trpc/server/adapters/next";
|
||||
104
calcom/packages/trpc/server/createContext.ts
Normal file
104
calcom/packages/trpc/server/createContext.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { GetServerSidePropsContext, NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Session } from "next-auth";
|
||||
import type { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import prisma, { readonlyPrisma } from "@calcom/prisma";
|
||||
import type { SelectedCalendar, User as PrismaUser } from "@calcom/prisma/client";
|
||||
|
||||
import type { CreateNextContextOptions } from "@trpc/server/adapters/next";
|
||||
|
||||
type CreateContextOptions =
|
||||
| (Omit<CreateNextContextOptions, "info"> & {
|
||||
info?: CreateNextContextOptions["info"];
|
||||
})
|
||||
| GetServerSidePropsContext;
|
||||
|
||||
export type CreateInnerContextOptions = {
|
||||
sourceIp?: string;
|
||||
session?: Session | null;
|
||||
locale: string;
|
||||
user?:
|
||||
| Omit<
|
||||
PrismaUser,
|
||||
| "locale"
|
||||
| "twoFactorSecret"
|
||||
| "emailVerified"
|
||||
| "password"
|
||||
| "identityProviderId"
|
||||
| "invitedTo"
|
||||
| "allowDynamicBooking"
|
||||
| "verified"
|
||||
> & {
|
||||
locale: Exclude<PrismaUser["locale"], null>;
|
||||
credentials?: Credential[];
|
||||
selectedCalendars?: Partial<SelectedCalendar>[];
|
||||
rawAvatar?: string;
|
||||
};
|
||||
i18n?: Awaited<ReturnType<typeof serverSideTranslations>>;
|
||||
} & Partial<CreateContextOptions>;
|
||||
|
||||
export type GetSessionFn =
|
||||
| ((_options: {
|
||||
req: GetServerSidePropsContext["req"] | NextApiRequest;
|
||||
res: GetServerSidePropsContext["res"] | NextApiResponse;
|
||||
}) => Promise<Session | null>)
|
||||
| (() => Promise<Session | null>);
|
||||
|
||||
export type InnerContext = CreateInnerContextOptions & {
|
||||
prisma: typeof prisma;
|
||||
insightsDb: typeof readonlyPrisma;
|
||||
};
|
||||
|
||||
/**
|
||||
* Inner context. Will always be available in your procedures, in contrast to the outer context.
|
||||
*
|
||||
* Also useful for:
|
||||
* - testing, so you don't have to mock Next.js' `req`/`res`
|
||||
* - tRPC's `createServerSideHelpers` where we don't have `req`/`res`
|
||||
*
|
||||
* @see https://trpc.io/docs/context#inner-and-outer-context
|
||||
*/
|
||||
export async function createContextInner(opts: CreateInnerContextOptions): Promise<InnerContext> {
|
||||
return {
|
||||
prisma,
|
||||
insightsDb: readonlyPrisma,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
|
||||
type Context = InnerContext & {
|
||||
req: CreateContextOptions["req"];
|
||||
res: CreateContextOptions["res"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates context for an incoming request
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
export const createContext = async (
|
||||
{ req, res }: CreateContextOptions,
|
||||
sessionGetter?: GetSessionFn
|
||||
): Promise<Context> => {
|
||||
const locale = await getLocale(req);
|
||||
|
||||
// This type may not be accurate if this request is coming from SSG init but they both should satisfy the requirements of getIP.
|
||||
// TODO: @sean - figure out a way to make getIP be happy with trpc req. params
|
||||
const sourceIp = getIP(req as NextApiRequest);
|
||||
const session = !!sessionGetter ? await sessionGetter({ req, res }) : null;
|
||||
const contextInner = await createContextInner({ locale, session, sourceIp });
|
||||
return {
|
||||
...contextInner,
|
||||
req,
|
||||
res,
|
||||
};
|
||||
};
|
||||
|
||||
export type TRPCContext = Awaited<ReturnType<typeof createContext>>;
|
||||
export type TRPCContextInner = Awaited<ReturnType<typeof createContextInner>>;
|
||||
export type WithLocale<T extends TRPCContext = any> = T &
|
||||
Required<Pick<CreateInnerContextOptions, "i18n" | "locale">>;
|
||||
export type WithSession<T extends TRPCContext = any> = T &
|
||||
Required<Pick<CreateInnerContextOptions, "session">>;
|
||||
96
calcom/packages/trpc/server/createNextApiHandler.ts
Normal file
96
calcom/packages/trpc/server/createNextApiHandler.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnyRouter } from "@trpc/server";
|
||||
import { createNextApiHandler as _createNextApiHandler } from "@trpc/server/adapters/next";
|
||||
|
||||
import { createContext as createTrpcContext } from "./createContext";
|
||||
|
||||
/**
|
||||
* Creates an API handler executed by Next.js.
|
||||
*/
|
||||
export function createNextApiHandler(router: AnyRouter, isPublic = false, namespace = "") {
|
||||
return _createNextApiHandler({
|
||||
router,
|
||||
/**
|
||||
* @link https://trpc.io/docs/context
|
||||
*/
|
||||
createContext: (opts) => {
|
||||
return createTrpcContext(opts);
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/error-handling
|
||||
*/
|
||||
onError({ error }) {
|
||||
if (error.code === "INTERNAL_SERVER_ERROR") {
|
||||
// send to bug reporting
|
||||
console.error("Something went wrong", error);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Enable query batching
|
||||
*/
|
||||
batching: {
|
||||
enabled: true,
|
||||
},
|
||||
/**
|
||||
* @link https://trpc.io/docs/caching#api-response-caching
|
||||
*/
|
||||
responseMeta({ ctx, paths, type, errors }) {
|
||||
const allOk = errors.length === 0;
|
||||
const isQuery = type === "query";
|
||||
const noHeaders = {};
|
||||
|
||||
// We cannot set headers on SSG queries
|
||||
if (!ctx?.res) return noHeaders;
|
||||
|
||||
const defaultHeaders: Record<"headers", Record<string, string>> = {
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const timezone = z.string().safeParse(ctx.req?.headers["x-vercel-ip-timezone"]);
|
||||
if (timezone.success) defaultHeaders.headers["x-cal-timezone"] = timezone.data;
|
||||
|
||||
// We need all these conditions to be true to set cache headers
|
||||
if (!(isPublic && allOk && isQuery)) return defaultHeaders;
|
||||
|
||||
// No cache by default
|
||||
defaultHeaders.headers["cache-control"] = `no-cache`;
|
||||
|
||||
if (isPublic && paths) {
|
||||
const FIVE_MINUTES_IN_SECONDS = 5 * 60;
|
||||
const ONE_YEAR_IN_SECONDS = 31536000;
|
||||
const SETTING_FOR_CACHED_BY_VERSION =
|
||||
process.env.NODE_ENV === "development" ? "no-cache" : `max-age=${ONE_YEAR_IN_SECONDS}`;
|
||||
|
||||
const cacheRules = {
|
||||
session: "no-cache",
|
||||
|
||||
// i18n and cityTimezones are now being accessed using the CalComVersion, which updates on every release,
|
||||
// letting the clients get the new versions when the version number changes.
|
||||
i18n: SETTING_FOR_CACHED_BY_VERSION,
|
||||
cityTimezones: SETTING_FOR_CACHED_BY_VERSION,
|
||||
|
||||
// FIXME: Using `max-age=1, stale-while-revalidate=60` fails some booking tests.
|
||||
"slots.getSchedule": `no-cache`,
|
||||
|
||||
// Feature Flags change but it might be okay to have a 5 minute cache to avoid burdening the servers with requests for this.
|
||||
// Note that feature flags can be used to quickly kill a feature if it's not working as expected. So, we have to keep fresh time lesser than the deployment time atleast
|
||||
"features.map": `max-age=${FIVE_MINUTES_IN_SECONDS}, stale-while-revalidate=60`, // "map" - Feature Flag Map
|
||||
} as const;
|
||||
|
||||
const prependNamespace = (key: string) =>
|
||||
(namespace ? `${namespace}.${key}` : key) as keyof typeof cacheRules;
|
||||
const matchedPath = paths.find((v) => prependNamespace(v) in cacheRules);
|
||||
if (matchedPath) {
|
||||
const cacheRule = cacheRules[prependNamespace(matchedPath)];
|
||||
|
||||
// We must set cdn-cache-control as well to ensure that Vercel doesn't strip stale-while-revalidate
|
||||
// https://vercel.com/docs/concepts/edge-network/caching#:~:text=If%20you%20set,in%20the%20response.
|
||||
defaultHeaders.headers["cache-control"] = defaultHeaders.headers["cdn-cache-control"] = cacheRule;
|
||||
}
|
||||
}
|
||||
|
||||
return defaultHeaders;
|
||||
},
|
||||
});
|
||||
}
|
||||
1
calcom/packages/trpc/server/index.ts
Normal file
1
calcom/packages/trpc/server/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "@trpc/server";
|
||||
@@ -0,0 +1,20 @@
|
||||
import { captureException as SentryCaptureException } from "@sentry/nextjs";
|
||||
|
||||
import { redactError } from "@calcom/lib/redactError";
|
||||
|
||||
import { middleware } from "../trpc";
|
||||
|
||||
const captureErrorsMiddleware = middleware(async ({ next }) => {
|
||||
const result = await next();
|
||||
if (result && !result.ok) {
|
||||
const cause = result.error.cause;
|
||||
if (!cause) {
|
||||
return result;
|
||||
}
|
||||
SentryCaptureException(cause);
|
||||
throw redactError(cause);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
export default captureErrorsMiddleware;
|
||||
11
calcom/packages/trpc/server/middlewares/perfMiddleware.ts
Normal file
11
calcom/packages/trpc/server/middlewares/perfMiddleware.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { middleware } from "../trpc";
|
||||
|
||||
const perfMiddleware = middleware(async ({ path, type, next }) => {
|
||||
performance.mark("Start");
|
||||
const result = await next();
|
||||
performance.mark("End");
|
||||
performance.measure(`[${result.ok ? "OK" : "ERROR"}][$1] ${type} '${path}'`, "Start", "End");
|
||||
return result;
|
||||
});
|
||||
|
||||
export default perfMiddleware;
|
||||
235
calcom/packages/trpc/server/middlewares/sessionMiddleware.ts
Normal file
235
calcom/packages/trpc/server/middlewares/sessionMiddleware.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TRPCContextInner } from "../createContext";
|
||||
import { middleware } from "../trpc";
|
||||
|
||||
type Maybe<T> = T | null | undefined;
|
||||
|
||||
export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<Session>) {
|
||||
const { prisma } = ctx;
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!session.user?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userFromDb = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: session.user.id,
|
||||
// Locked users can't login
|
||||
locked: false,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
bio: true,
|
||||
avatarUrl: true,
|
||||
timeZone: true,
|
||||
weekStart: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
defaultScheduleId: true,
|
||||
bufferTime: true,
|
||||
theme: true,
|
||||
appTheme: true,
|
||||
createdDate: true,
|
||||
hideBranding: true,
|
||||
twoFactorEnabled: true,
|
||||
disableImpersonation: true,
|
||||
identityProvider: true,
|
||||
identityProviderId: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
movedToProfileId: true,
|
||||
selectedCalendars: {
|
||||
select: {
|
||||
externalId: true,
|
||||
integration: true,
|
||||
},
|
||||
},
|
||||
completedOnboarding: true,
|
||||
destinationCalendar: true,
|
||||
locale: true,
|
||||
timeFormat: true,
|
||||
trialEndsAt: true,
|
||||
metadata: true,
|
||||
role: true,
|
||||
allowDynamicBooking: true,
|
||||
allowSEOIndexing: true,
|
||||
receiveMonthlyDigestEmail: true,
|
||||
},
|
||||
});
|
||||
|
||||
// some hacks to make sure `username` and `email` are never inferred as `null`
|
||||
if (!userFromDb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const upId = session.upId;
|
||||
|
||||
const user = await UserRepository.enrichUserWithTheProfile({
|
||||
user: userFromDb,
|
||||
upId,
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`getUserFromSession: enriched user with profile - ${ctx.req?.url}`,
|
||||
safeStringify({ user, userFromDb, upId })
|
||||
);
|
||||
|
||||
const { email, username, id } = user;
|
||||
if (!email || !id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userMetaData = userMetadata.parse(user.metadata || {});
|
||||
const orgMetadata = teamMetadataSchema.parse(user.profile?.organization?.metadata || {});
|
||||
// This helps to prevent reaching the 4MB payload limit by avoiding base64 and instead passing the avatar url
|
||||
|
||||
const locale = user?.locale ?? ctx.locale;
|
||||
|
||||
const isOrgAdmin = !!user.profile?.organization?.members.filter(
|
||||
(member) => (member.role === "ADMIN" || member.role === "OWNER") && member.userId === user.id
|
||||
).length;
|
||||
|
||||
if (isOrgAdmin) {
|
||||
logger.debug("User is an org admin", safeStringify({ userId: user.id }));
|
||||
} else {
|
||||
logger.debug("User is not an org admin", safeStringify({ userId: user.id }));
|
||||
}
|
||||
// Want to reduce the amount of data being sent
|
||||
if (isOrgAdmin && user.profile?.organization?.members) {
|
||||
user.profile.organization.members = [];
|
||||
}
|
||||
|
||||
const organization = {
|
||||
...user.profile?.organization,
|
||||
id: user.profile?.organization?.id ?? null,
|
||||
isOrgAdmin,
|
||||
metadata: orgMetadata,
|
||||
requestedSlug: orgMetadata?.requestedSlug ?? null,
|
||||
};
|
||||
|
||||
return {
|
||||
...user,
|
||||
avatar: `${WEBAPP_URL}/${user.username}/avatar.png?${organization.id}` && `orgId=${organization.id}`,
|
||||
// TODO: OrgNewSchema - later - We could consolidate the props in user.profile?.organization as organization is a profile thing now.
|
||||
organization,
|
||||
organizationId: organization.id,
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
locale,
|
||||
defaultBookerLayouts: userMetaData?.defaultBookerLayouts || null,
|
||||
};
|
||||
}
|
||||
|
||||
export type UserFromSession = Awaited<ReturnType<typeof getUserFromSession>>;
|
||||
|
||||
const getSession = async (ctx: TRPCContextInner) => {
|
||||
const { req, res } = ctx;
|
||||
const { getServerSession } = await import("@calcom/features/auth/lib/getServerSession");
|
||||
return req ? await getServerSession({ req, res }) : null;
|
||||
};
|
||||
|
||||
const getUserSession = async (ctx: TRPCContextInner) => {
|
||||
/**
|
||||
* It is possible that the session and user have already been added to the context by a previous middleware
|
||||
* or when creating the context
|
||||
*/
|
||||
const session = ctx.session || (await getSession(ctx));
|
||||
const user = session ? await getUserFromSession(ctx, session) : null;
|
||||
let foundProfile = null;
|
||||
// Check authorization for profile
|
||||
if (session?.profileId && user?.id) {
|
||||
foundProfile = await ProfileRepository.findByUserIdAndProfileId({
|
||||
userId: user.id,
|
||||
profileId: session.profileId,
|
||||
});
|
||||
if (!foundProfile) {
|
||||
logger.error(
|
||||
"Profile not found or not authorized",
|
||||
safeStringify({ profileId: session.profileId, userId: user?.id })
|
||||
);
|
||||
// TODO: Test that logout should happen automatically
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Profile not found or not authorized" });
|
||||
}
|
||||
}
|
||||
|
||||
let sessionWithUpId = null;
|
||||
if (session) {
|
||||
let upId = session.upId;
|
||||
if (!upId) {
|
||||
upId = foundProfile?.upId ?? `usr-${user?.id}`;
|
||||
}
|
||||
|
||||
if (!upId) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "No upId found for session" });
|
||||
}
|
||||
sessionWithUpId = {
|
||||
...session,
|
||||
upId,
|
||||
};
|
||||
}
|
||||
return { user, session: sessionWithUpId };
|
||||
};
|
||||
|
||||
const sessionMiddleware = middleware(async ({ ctx, next }) => {
|
||||
const middlewareStart = performance.now();
|
||||
const { user, session } = await getUserSession(ctx);
|
||||
const middlewareEnd = performance.now();
|
||||
logger.debug("Perf:t.sessionMiddleware", middlewareEnd - middlewareStart);
|
||||
return next({
|
||||
ctx: { user, session },
|
||||
});
|
||||
});
|
||||
|
||||
export const isAuthed = middleware(async ({ ctx, next }) => {
|
||||
const middlewareStart = performance.now();
|
||||
|
||||
const { user, session } = await getUserSession(ctx);
|
||||
|
||||
const middlewareEnd = performance.now();
|
||||
logger.debug("Perf:t.isAuthed", middlewareEnd - middlewareStart);
|
||||
|
||||
if (!user || !session) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
return next({
|
||||
ctx: { user, session },
|
||||
});
|
||||
});
|
||||
|
||||
export const isAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
|
||||
const { user } = ctx;
|
||||
if (user?.role !== "ADMIN") {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({ ctx: { user: user } });
|
||||
});
|
||||
|
||||
// Org admins can be admins or owners
|
||||
export const isOrgAdminMiddleware = isAuthed.unstable_pipe(({ ctx, next }) => {
|
||||
const { user } = ctx;
|
||||
if (!user?.organization?.isOrgAdmin) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return next({ ctx: { user: user } });
|
||||
});
|
||||
|
||||
export default sessionMiddleware;
|
||||
33
calcom/packages/trpc/server/procedures/authedProcedure.ts
Normal file
33
calcom/packages/trpc/server/procedures/authedProcedure.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import captureErrorsMiddleware from "../middlewares/captureErrorsMiddleware";
|
||||
import perfMiddleware from "../middlewares/perfMiddleware";
|
||||
import { isAdminMiddleware, isAuthed, isOrgAdminMiddleware } from "../middlewares/sessionMiddleware";
|
||||
import { procedure } from "../trpc";
|
||||
import publicProcedure from "./publicProcedure";
|
||||
|
||||
/*interface IRateLimitOptions {
|
||||
intervalInMs: number;
|
||||
limit: number;
|
||||
}
|
||||
const isRateLimitedByUserIdMiddleware = ({ intervalInMs, limit }: IRateLimitOptions) =>
|
||||
middleware(({ ctx, next }) => {
|
||||
// validate user exists
|
||||
if (!ctx.user) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const { isRateLimited } = rateLimit({ intervalInMs }).check(limit, ctx.user.id.toString());
|
||||
|
||||
if (isRateLimited) {
|
||||
throw new TRPCError({ code: "TOO_MANY_REQUESTS" });
|
||||
}
|
||||
|
||||
return next({ ctx: { user: ctx.user, session: ctx.session } });
|
||||
});
|
||||
*/
|
||||
const authedProcedure = procedure.use(captureErrorsMiddleware).use(perfMiddleware).use(isAuthed);
|
||||
/*export const authedRateLimitedProcedure = ({ intervalInMs, limit }: IRateLimitOptions) =>
|
||||
authedProcedure.use(isRateLimitedByUserIdMiddleware({ intervalInMs, limit }));*/
|
||||
export const authedAdminProcedure = publicProcedure.use(captureErrorsMiddleware).use(isAdminMiddleware);
|
||||
export const authedOrgAdminProcedure = publicProcedure.use(captureErrorsMiddleware).use(isOrgAdminMiddleware);
|
||||
|
||||
export default authedProcedure;
|
||||
@@ -0,0 +1,7 @@
|
||||
import captureErrorsMiddleware from "../middlewares/captureErrorsMiddleware";
|
||||
import perfMiddleware from "../middlewares/perfMiddleware";
|
||||
import { tRPCContext } from "../trpc";
|
||||
|
||||
const publicProcedure = tRPCContext.procedure.use(captureErrorsMiddleware).use(perfMiddleware);
|
||||
|
||||
export default publicProcedure;
|
||||
17
calcom/packages/trpc/server/routers/_app.ts
Normal file
17
calcom/packages/trpc/server/routers/_app.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* This file contains the root router of your tRPC-backend
|
||||
*/
|
||||
import { router } from "../trpc";
|
||||
import { viewerRouter } from "./viewer/_router";
|
||||
|
||||
/**
|
||||
* Create your application's root router
|
||||
* If you want to use SSG, you need export this
|
||||
* @link https://trpc.io/docs/ssg
|
||||
* @link https://trpc.io/docs/router
|
||||
*/
|
||||
export const appRouter = router({
|
||||
viewer: viewerRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
500
calcom/packages/trpc/server/routers/loggedInViewer/_router.tsx
Normal file
500
calcom/packages/trpc/server/routers/loggedInViewer/_router.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
import authedProcedure from "../../procedures/authedProcedure";
|
||||
import { importHandler, router } from "../../trpc";
|
||||
import { ZAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema";
|
||||
import { ZAppByIdInputSchema } from "./appById.schema";
|
||||
import { ZAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema";
|
||||
import { ZConnectAndJoinInputSchema } from "./connectAndJoin.schema";
|
||||
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
|
||||
import { ZDeleteCredentialInputSchema } from "./deleteCredential.schema";
|
||||
import { ZDeleteMeInputSchema } from "./deleteMe.schema";
|
||||
import { ZEventTypeOrderInputSchema } from "./eventTypeOrder.schema";
|
||||
import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema";
|
||||
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||
import { ZIntegrationsInputSchema } from "./integrations.schema";
|
||||
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||
import { ZOutOfOfficeInputSchema, ZOutOfOfficeDelete } from "./outOfOffice.schema";
|
||||
import { me } from "./procedures/me";
|
||||
import { teamsAndUserProfilesQuery } from "./procedures/teamsAndUserProfilesQuery";
|
||||
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
||||
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
||||
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
|
||||
import { ZUpdateProfileInputSchema } from "./updateProfile.schema";
|
||||
import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema";
|
||||
import { ZWorkflowOrderInputSchema } from "./workflowOrder.schema";
|
||||
|
||||
const NAMESPACE = "loggedInViewer";
|
||||
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
type AppsRouterHandlerCache = {
|
||||
me?: typeof import("./me.handler").meHandler;
|
||||
shouldVerifyEmail?: typeof import("./shouldVerifyEmail.handler").shouldVerifyEmailHandler;
|
||||
deleteMe?: typeof import("./deleteMe.handler").deleteMeHandler;
|
||||
deleteMeWithoutPassword?: typeof import("./deleteMeWithoutPassword.handler").deleteMeWithoutPasswordHandler;
|
||||
connectedCalendars?: typeof import("./connectedCalendars.handler").connectedCalendarsHandler;
|
||||
setDestinationCalendar?: typeof import("./setDestinationCalendar.handler").setDestinationCalendarHandler;
|
||||
integrations?: typeof import("./integrations.handler").integrationsHandler;
|
||||
appById?: typeof import("./appById.handler").appByIdHandler;
|
||||
appCredentialsByType?: typeof import("./appCredentialsByType.handler").appCredentialsByTypeHandler;
|
||||
stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler;
|
||||
updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler;
|
||||
eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler;
|
||||
routingFormOrder?: typeof import("./routingFormOrder.handler").routingFormOrderHandler;
|
||||
workflowOrder?: typeof import("./workflowOrder.handler").workflowOrderHandler;
|
||||
submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler;
|
||||
locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler;
|
||||
deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler;
|
||||
bookingUnconfirmedCount?: typeof import("./bookingUnconfirmedCount.handler").bookingUnconfirmedCountHandler;
|
||||
getCalVideoRecordings?: typeof import("./getCalVideoRecordings.handler").getCalVideoRecordingsHandler;
|
||||
getDownloadLinkOfCalVideoRecordings?: typeof import("./getDownloadLinkOfCalVideoRecordings.handler").getDownloadLinkOfCalVideoRecordingsHandler;
|
||||
getUsersDefaultConferencingApp?: typeof import("./getUsersDefaultConferencingApp.handler").getUsersDefaultConferencingAppHandler;
|
||||
updateUserDefaultConferencingApp?: typeof import("./updateUserDefaultConferencingApp.handler").updateUserDefaultConferencingAppHandler;
|
||||
teamsAndUserProfilesQuery?: typeof import("./teamsAndUserProfilesQuery.handler").teamsAndUserProfilesQuery;
|
||||
getUserTopBanners?: typeof import("./getUserTopBanners.handler").getUserTopBannersHandler;
|
||||
connectAndJoin?: typeof import("./connectAndJoin.handler").Handler;
|
||||
outOfOfficeCreate?: typeof import("./outOfOffice.handler").outOfOfficeCreate;
|
||||
outOfOfficeEntriesList?: typeof import("./outOfOffice.handler").outOfOfficeEntriesList;
|
||||
outOfOfficeEntryDelete?: typeof import("./outOfOffice.handler").outOfOfficeEntryDelete;
|
||||
addSecondaryEmail?: typeof import("./addSecondaryEmail.handler").addSecondaryEmailHandler;
|
||||
getTravelSchedules?: typeof import("./getTravelSchedules.handler").getTravelSchedulesHandler;
|
||||
outOfOfficeReasonList?: typeof import("./outOfOfficeReasons.handler").outOfOfficeReasonList;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: AppsRouterHandlerCache = {};
|
||||
|
||||
export const loggedInViewerRouter = router({
|
||||
me,
|
||||
|
||||
deleteMe: authedProcedure.input(ZDeleteMeInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteMe) {
|
||||
UNSTABLE_HANDLER_CACHE.deleteMe = (await import("./deleteMe.handler")).deleteMeHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteMe) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.deleteMe({ ctx, input });
|
||||
}),
|
||||
|
||||
deleteMeWithoutPassword: authedProcedure.mutation(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) {
|
||||
UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword = (
|
||||
await import("./deleteMeWithoutPassword.handler")
|
||||
).deleteMeWithoutPasswordHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.deleteMeWithoutPassword({ ctx });
|
||||
}),
|
||||
|
||||
connectedCalendars: authedProcedure.input(ZConnectedCalendarsInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) {
|
||||
UNSTABLE_HANDLER_CACHE.connectedCalendars = (
|
||||
await import("./connectedCalendars.handler")
|
||||
).connectedCalendarsHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.connectedCalendars) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.connectedCalendars({ ctx, input });
|
||||
}),
|
||||
|
||||
setDestinationCalendar: authedProcedure
|
||||
.input(ZSetDestinationCalendarInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) {
|
||||
UNSTABLE_HANDLER_CACHE.setDestinationCalendar = (
|
||||
await import("./setDestinationCalendar.handler")
|
||||
).setDestinationCalendarHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.setDestinationCalendar) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.setDestinationCalendar({ ctx, input });
|
||||
}),
|
||||
|
||||
integrations: authedProcedure.input(ZIntegrationsInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.integrations) {
|
||||
UNSTABLE_HANDLER_CACHE.integrations = (await import("./integrations.handler")).integrationsHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.integrations) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.integrations({ ctx, input });
|
||||
}),
|
||||
|
||||
appById: authedProcedure.input(ZAppByIdInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.appById) {
|
||||
UNSTABLE_HANDLER_CACHE.appById = (await import("./appById.handler")).appByIdHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.appById) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.appById({ ctx, input });
|
||||
}),
|
||||
|
||||
appCredentialsByType: authedProcedure
|
||||
.input(ZAppCredentialsByTypeInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) {
|
||||
UNSTABLE_HANDLER_CACHE.appCredentialsByType = (
|
||||
await import("./appCredentialsByType.handler")
|
||||
).appCredentialsByTypeHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.appCredentialsByType) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.appCredentialsByType({ ctx, input });
|
||||
}),
|
||||
|
||||
stripeCustomer: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) {
|
||||
UNSTABLE_HANDLER_CACHE.stripeCustomer = (
|
||||
await import("./stripeCustomer.handler")
|
||||
).stripeCustomerHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.stripeCustomer) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.stripeCustomer({ ctx });
|
||||
}),
|
||||
|
||||
updateProfile: authedProcedure.input(ZUpdateProfileInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateProfile) {
|
||||
UNSTABLE_HANDLER_CACHE.updateProfile = (await import("./updateProfile.handler")).updateProfileHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateProfile) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.updateProfile({ ctx, input });
|
||||
}),
|
||||
|
||||
unlinkConnectedAccount: authedProcedure.mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("unlinkConnectedAccount"),
|
||||
() => import("./unlinkConnectedAccount.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
|
||||
eventTypeOrder: authedProcedure.input(ZEventTypeOrderInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) {
|
||||
UNSTABLE_HANDLER_CACHE.eventTypeOrder = (
|
||||
await import("./eventTypeOrder.handler")
|
||||
).eventTypeOrderHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.eventTypeOrder) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
routingFormOrder: authedProcedure.input(ZRoutingFormOrderInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
|
||||
UNSTABLE_HANDLER_CACHE.routingFormOrder = (
|
||||
await import("./routingFormOrder.handler")
|
||||
).routingFormOrderHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.routingFormOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
workflowOrder: authedProcedure.input(ZWorkflowOrderInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
|
||||
UNSTABLE_HANDLER_CACHE.workflowOrder = (await import("./workflowOrder.handler")).workflowOrderHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.workflowOrder({ ctx, input });
|
||||
}),
|
||||
|
||||
//Comment for PR: eventTypePosition is not used anywhere
|
||||
submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.submitFeedback) {
|
||||
UNSTABLE_HANDLER_CACHE.submitFeedback = (
|
||||
await import("./submitFeedback.handler")
|
||||
).submitFeedbackHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.submitFeedback) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.submitFeedback({ ctx, input });
|
||||
}),
|
||||
|
||||
locationOptions: authedProcedure.input(ZLocationOptionsInputSchema).query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.locationOptions) {
|
||||
UNSTABLE_HANDLER_CACHE.locationOptions = (
|
||||
await import("./locationOptions.handler")
|
||||
).locationOptionsHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.locationOptions) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.locationOptions({ ctx, input });
|
||||
}),
|
||||
|
||||
deleteCredential: authedProcedure.input(ZDeleteCredentialInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteCredential) {
|
||||
UNSTABLE_HANDLER_CACHE.deleteCredential = (
|
||||
await import("./deleteCredential.handler")
|
||||
).deleteCredentialHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.deleteCredential) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.deleteCredential({ ctx, input });
|
||||
}),
|
||||
|
||||
bookingUnconfirmedCount: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) {
|
||||
UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount = (
|
||||
await import("./bookingUnconfirmedCount.handler")
|
||||
).bookingUnconfirmedCountHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.bookingUnconfirmedCount({ ctx });
|
||||
}),
|
||||
|
||||
getCalVideoRecordings: authedProcedure
|
||||
.input(ZGetCalVideoRecordingsInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) {
|
||||
UNSTABLE_HANDLER_CACHE.getCalVideoRecordings = (
|
||||
await import("./getCalVideoRecordings.handler")
|
||||
).getCalVideoRecordingsHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getCalVideoRecordings) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getCalVideoRecordings({ ctx, input });
|
||||
}),
|
||||
|
||||
getUserTopBanners: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUserTopBanners) {
|
||||
UNSTABLE_HANDLER_CACHE.getUserTopBanners = (
|
||||
await import("./getUserTopBanners.handler")
|
||||
).getUserTopBannersHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUserTopBanners) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getUserTopBanners({ ctx });
|
||||
}),
|
||||
|
||||
getDownloadLinkOfCalVideoRecordings: authedProcedure
|
||||
.input(ZGetDownloadLinkOfCalVideoRecordingsInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) {
|
||||
UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings = (
|
||||
await import("./getDownloadLinkOfCalVideoRecordings.handler")
|
||||
).getDownloadLinkOfCalVideoRecordingsHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getDownloadLinkOfCalVideoRecordings({ ctx, input });
|
||||
}),
|
||||
|
||||
getUsersDefaultConferencingApp: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) {
|
||||
UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp = (
|
||||
await import("./getUsersDefaultConferencingApp.handler")
|
||||
).getUsersDefaultConferencingAppHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getUsersDefaultConferencingApp({ ctx });
|
||||
}),
|
||||
|
||||
updateUserDefaultConferencingApp: authedProcedure
|
||||
.input(ZUpdateUserDefaultConferencingAppInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) {
|
||||
UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp = (
|
||||
await import("./updateUserDefaultConferencingApp.handler")
|
||||
).updateUserDefaultConferencingAppHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.updateUserDefaultConferencingApp({ ctx, input });
|
||||
}),
|
||||
shouldVerifyEmail: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
|
||||
UNSTABLE_HANDLER_CACHE.shouldVerifyEmail = (
|
||||
await import("./shouldVerifyEmail.handler")
|
||||
).shouldVerifyEmailHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.shouldVerifyEmail) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.shouldVerifyEmail({ ctx });
|
||||
}),
|
||||
teamsAndUserProfilesQuery,
|
||||
connectAndJoin: authedProcedure.input(ZConnectAndJoinInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
|
||||
UNSTABLE_HANDLER_CACHE.connectAndJoin = (await import("./connectAndJoin.handler")).Handler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.connectAndJoin) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.connectAndJoin({ ctx, input });
|
||||
}),
|
||||
outOfOfficeCreate: authedProcedure.input(ZOutOfOfficeInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeCreate = (await import("./outOfOffice.handler")).outOfOfficeCreate;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeCreate) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeCreate({ ctx, input });
|
||||
}),
|
||||
outOfOfficeEntriesList: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList = (
|
||||
await import("./outOfOffice.handler")
|
||||
).outOfOfficeEntriesList;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntriesList({ ctx });
|
||||
}),
|
||||
outOfOfficeEntryDelete: authedProcedure.input(ZOutOfOfficeDelete).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete = (
|
||||
await import("./outOfOffice.handler")
|
||||
).outOfOfficeEntryDelete;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeEntryDelete({ ctx, input });
|
||||
}),
|
||||
addSecondaryEmail: authedProcedure.input(ZAddSecondaryEmailInputSchema).mutation(async ({ ctx, input }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.addSecondaryEmail) {
|
||||
UNSTABLE_HANDLER_CACHE.addSecondaryEmail = (
|
||||
await import("./addSecondaryEmail.handler")
|
||||
).addSecondaryEmailHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.addSecondaryEmail) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.addSecondaryEmail({ ctx, input });
|
||||
}),
|
||||
getTravelSchedules: authedProcedure.query(async ({ ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.getTravelSchedules) {
|
||||
UNSTABLE_HANDLER_CACHE.getTravelSchedules = (
|
||||
await import("./getTravelSchedules.handler")
|
||||
).getTravelSchedulesHandler;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.getTravelSchedules) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.getTravelSchedules({ ctx });
|
||||
}),
|
||||
outOfOfficeReasonList: authedProcedure.query(async () => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
|
||||
UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList = (
|
||||
await import("./outOfOfficeReasons.handler")
|
||||
).outOfOfficeReasonList;
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.outOfOfficeReasonList();
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
||||
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TAddSecondaryEmailInputSchema } from "./addSecondaryEmail.schema";
|
||||
|
||||
type AddSecondaryEmailOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
res?: NextApiResponse | GetServerSidePropsContext["res"];
|
||||
};
|
||||
input: TAddSecondaryEmailInputSchema;
|
||||
};
|
||||
|
||||
export const addSecondaryEmailHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const existingPrimaryEmail = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: input.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingPrimaryEmail) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Email already taken" });
|
||||
}
|
||||
|
||||
const existingSecondaryEmail = await prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email: input.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingSecondaryEmail) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Email already taken" });
|
||||
}
|
||||
|
||||
const updatedData = await prisma.secondaryEmail.create({
|
||||
data: { ...input, userId: user.id },
|
||||
});
|
||||
|
||||
await sendEmailVerification({
|
||||
email: updatedData.email,
|
||||
username: user?.username ?? undefined,
|
||||
language: user.locale,
|
||||
secondaryEmailId: updatedData.id,
|
||||
});
|
||||
|
||||
return {
|
||||
data: updatedData,
|
||||
message: "Secondary email added successfully",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAddSecondaryEmailInputSchema = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type TAddSecondaryEmailInputSchema = z.infer<typeof ZAddSecondaryEmailInputSchema>;
|
||||
@@ -0,0 +1,32 @@
|
||||
import getApps from "@calcom/app-store/utils";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TAppByIdInputSchema } from "./appById.schema";
|
||||
|
||||
type AppByIdOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TAppByIdInputSchema;
|
||||
};
|
||||
|
||||
export const appByIdHandler = async ({ ctx, input }: AppByIdOptions) => {
|
||||
const { user } = ctx;
|
||||
const appId = input.appId;
|
||||
const credentials = await getUsersCredentials(user);
|
||||
const apps = getApps(credentials);
|
||||
const appFromDb = apps.find((app) => app.slug === appId);
|
||||
if (!appFromDb) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find app ${appId}` });
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { credential: _, credentials: _1, ...app } = appFromDb;
|
||||
return {
|
||||
isInstalled: appFromDb.credentials.length,
|
||||
...app,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAppByIdInputSchema = z.object({
|
||||
appId: z.string(),
|
||||
});
|
||||
|
||||
export type TAppByIdInputSchema = z.infer<typeof ZAppByIdInputSchema>;
|
||||
@@ -0,0 +1,39 @@
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TAppCredentialsByTypeInputSchema } from "./appCredentialsByType.schema";
|
||||
|
||||
type AppCredentialsByTypeOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TAppCredentialsByTypeInputSchema;
|
||||
};
|
||||
|
||||
/** Used for grabbing credentials on specific app pages */
|
||||
export const appCredentialsByTypeHandler = async ({ ctx, input }: AppCredentialsByTypeOptions) => {
|
||||
const { user } = ctx;
|
||||
const userAdminTeams = await UserRepository.getUserAdminTeams(ctx.user.id);
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId: user.id },
|
||||
{
|
||||
teamId: {
|
||||
in: userAdminTeams,
|
||||
},
|
||||
},
|
||||
],
|
||||
type: input.appType,
|
||||
},
|
||||
});
|
||||
|
||||
// For app pages need to return which teams the user can install the app on
|
||||
// return user.credentials.filter((app) => app.type == input.appType).map((credential) => credential.id);
|
||||
return {
|
||||
credentials,
|
||||
userAdminTeams,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZAppCredentialsByTypeInputSchema = z.object({
|
||||
appType: z.string(),
|
||||
});
|
||||
|
||||
export type TAppCredentialsByTypeInputSchema = z.infer<typeof ZAppCredentialsByTypeInputSchema>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type BookingUnconfirmedCountOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const bookingUnconfirmedCountHandler = async ({ ctx }: BookingUnconfirmedCountOptions) => {
|
||||
const { user } = ctx;
|
||||
const count = await prisma.booking.count({
|
||||
where: {
|
||||
status: BookingStatus.PENDING,
|
||||
userId: user.id,
|
||||
endTime: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
const recurringGrouping = await prisma.booking.groupBy({
|
||||
by: ["recurringEventId"],
|
||||
_count: {
|
||||
recurringEventId: true,
|
||||
},
|
||||
where: {
|
||||
recurringEventId: { not: { equals: null } },
|
||||
status: { equals: "PENDING" },
|
||||
userId: user.id,
|
||||
endTime: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
return recurringGrouping.reduce((prev, current) => {
|
||||
// recurringEventId is the total number of recurring instances for a booking
|
||||
// we need to subtract all but one, to represent a single recurring booking
|
||||
return prev - (current._count?.recurringEventId - 1);
|
||||
}, count);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import { type InvalidAppCredentialBannerProps } from "@calcom/features/users/components/InvalidAppCredentialsBanner";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/client";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type checkInvalidAppCredentialsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const checkInvalidAppCredentials = async ({ ctx }: checkInvalidAppCredentialsOptions) => {
|
||||
const userId = ctx.user.id;
|
||||
|
||||
const apps = await prisma.credential.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: userId,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: userId,
|
||||
accepted: true,
|
||||
role: { in: [MembershipRole.ADMIN, MembershipRole.OWNER] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
invalid: true,
|
||||
},
|
||||
select: {
|
||||
appId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const appNamesAndSlugs: InvalidAppCredentialBannerProps[] = [];
|
||||
for (const app of apps) {
|
||||
if (app.appId) {
|
||||
const appId = app.appId;
|
||||
const appMeta = await getAppFromSlug(appId);
|
||||
const name = appMeta ? appMeta.name : appId;
|
||||
appNamesAndSlugs.push({ slug: appId, name });
|
||||
}
|
||||
}
|
||||
|
||||
return appNamesAndSlugs;
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import { sendScheduledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TConnectAndJoinInputSchema } from "./connectAndJoin.schema";
|
||||
|
||||
type Options = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TConnectAndJoinInputSchema;
|
||||
};
|
||||
|
||||
export const Handler = async ({ ctx, input }: Options) => {
|
||||
const { token } = input;
|
||||
const { user } = ctx;
|
||||
const isLoggedInUserPartOfOrg = !!user.organization.id;
|
||||
|
||||
if (!isLoggedInUserPartOfOrg) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Logged in user is not member of Organization" });
|
||||
}
|
||||
|
||||
const tOrganizer = await getTranslation(user?.locale ?? "en", "common");
|
||||
|
||||
const instantMeetingToken = await prisma.instantMeetingToken.findUnique({
|
||||
select: {
|
||||
expires: true,
|
||||
teamId: true,
|
||||
booking: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
token,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Check if logged in user belong to current team
|
||||
if (!instantMeetingToken) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "token_not_found" });
|
||||
}
|
||||
|
||||
if (!instantMeetingToken.booking?.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "token_invalid_expired" });
|
||||
}
|
||||
|
||||
// Check if token has not expired
|
||||
if (instantMeetingToken.expires < new Date()) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "token_invalid_expired" });
|
||||
}
|
||||
|
||||
// Check if Booking is already accepted by any other user
|
||||
let isBookingAlreadyAcceptedBySomeoneElse = false;
|
||||
if (
|
||||
instantMeetingToken.booking.status === BookingStatus.ACCEPTED &&
|
||||
instantMeetingToken.booking?.user?.id !== user.id
|
||||
) {
|
||||
isBookingAlreadyAcceptedBySomeoneElse = true;
|
||||
}
|
||||
|
||||
// Update User in Booking
|
||||
const updatedBooking = await prisma.booking.update({
|
||||
where: {
|
||||
id: instantMeetingToken.booking.id,
|
||||
},
|
||||
data: {
|
||||
...(isBookingAlreadyAcceptedBySomeoneElse
|
||||
? { status: BookingStatus.ACCEPTED }
|
||||
: {
|
||||
status: BookingStatus.ACCEPTED,
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
title: true,
|
||||
description: true,
|
||||
customInputs: true,
|
||||
startTime: true,
|
||||
references: true,
|
||||
endTime: true,
|
||||
attendees: true,
|
||||
eventTypeId: true,
|
||||
responses: true,
|
||||
metadata: true,
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
owner: true,
|
||||
teamId: true,
|
||||
title: true,
|
||||
slug: true,
|
||||
requiresConfirmation: true,
|
||||
currency: true,
|
||||
length: true,
|
||||
description: true,
|
||||
price: true,
|
||||
bookingFields: true,
|
||||
disableGuests: true,
|
||||
metadata: true,
|
||||
customInputs: true,
|
||||
parentId: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
userId: true,
|
||||
id: true,
|
||||
uid: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
const locationVideoCallUrl = bookingMetadataSchema.parse(updatedBooking.metadata || {})?.videoCallUrl;
|
||||
|
||||
if (!locationVideoCallUrl) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "meeting_url_not_found" });
|
||||
}
|
||||
|
||||
const videoCallReference = updatedBooking.references.find((reference) => reference.type.includes("_video"));
|
||||
const videoCallData = {
|
||||
type: videoCallReference?.type,
|
||||
id: videoCallReference?.meetingId,
|
||||
password: videoCallReference?.meetingPassword,
|
||||
url: videoCallReference?.meetingUrl,
|
||||
};
|
||||
|
||||
const { eventType } = updatedBooking;
|
||||
|
||||
// Send Scheduled Email to Organizer and Attendees
|
||||
|
||||
const translations = new Map();
|
||||
const attendeesListPromises = updatedBooking.attendees.map(async (attendee) => {
|
||||
const locale = attendee.locale ?? "en";
|
||||
let translate = translations.get(locale);
|
||||
if (!translate) {
|
||||
translate = await getTranslation(locale, "common");
|
||||
translations.set(locale, translate);
|
||||
}
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: {
|
||||
translate,
|
||||
locale,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
|
||||
const evt: CalendarEvent = {
|
||||
type: updatedBooking?.eventType?.slug as string,
|
||||
title: updatedBooking.title,
|
||||
description: updatedBooking.description,
|
||||
...getCalEventResponses({
|
||||
bookingFields: eventType?.bookingFields ?? null,
|
||||
booking: updatedBooking,
|
||||
}),
|
||||
customInputs: isPrismaObjOrUndefined(updatedBooking.customInputs),
|
||||
startTime: updatedBooking.startTime.toISOString(),
|
||||
endTime: updatedBooking.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: user.email,
|
||||
name: user.name || "Unnamed",
|
||||
username: user.username || undefined,
|
||||
timeZone: user.timeZone,
|
||||
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
|
||||
language: { translate: tOrganizer, locale: user.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
location: updatedBooking.location ?? "",
|
||||
uid: updatedBooking.uid,
|
||||
requiresConfirmation: false,
|
||||
eventTypeId: eventType?.id,
|
||||
videoCallData,
|
||||
};
|
||||
|
||||
await sendScheduledEmails(
|
||||
{
|
||||
...evt,
|
||||
},
|
||||
undefined,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
return { isBookingAlreadyAcceptedBySomeoneElse, meetingUrl: locationVideoCallUrl };
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZConnectAndJoinInputSchema = z.object({
|
||||
token: z.string(),
|
||||
});
|
||||
|
||||
export type TConnectAndJoinInputSchema = z.infer<typeof ZConnectAndJoinInputSchema>;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getConnectedDestinationCalendars } from "@calcom/lib/getConnectedDestinationCalendars";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
|
||||
|
||||
type ConnectedCalendarsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TConnectedCalendarsInputSchema;
|
||||
};
|
||||
|
||||
export const connectedCalendarsHandler = async ({ ctx, input }: ConnectedCalendarsOptions) => {
|
||||
const { user } = ctx;
|
||||
const onboarding = input?.onboarding || false;
|
||||
|
||||
const { connectedCalendars, destinationCalendar } = await getConnectedDestinationCalendars(
|
||||
user,
|
||||
onboarding,
|
||||
prisma
|
||||
);
|
||||
|
||||
return {
|
||||
connectedCalendars,
|
||||
destinationCalendar,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZConnectedCalendarsInputSchema = z
|
||||
.object({
|
||||
onboarding: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TConnectedCalendarsInputSchema = z.infer<typeof ZConnectedCalendarsInputSchema>;
|
||||
@@ -0,0 +1,460 @@
|
||||
import z from "zod";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { DailyLocationType } from "@calcom/core/location";
|
||||
import { sendCancelledEmails } from "@calcom/emails";
|
||||
import { getCalEventResponses } from "@calcom/features/bookings/lib/getCalEventResponses";
|
||||
import { deleteWebhookScheduledTriggers } from "@calcom/features/webhooks/lib/scheduleTrigger";
|
||||
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
|
||||
import { deletePayment } from "@calcom/lib/payment/deletePayment";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { bookingMinimalSelect, prisma } from "@calcom/prisma";
|
||||
import { AppCategories, BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { EventTypeAppMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TDeleteCredentialInputSchema } from "./deleteCredential.schema";
|
||||
|
||||
type DeleteCredentialOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteCredentialInputSchema;
|
||||
};
|
||||
|
||||
type App = {
|
||||
slug: string;
|
||||
categories: AppCategories[];
|
||||
dirName: string;
|
||||
} | null;
|
||||
|
||||
const isVideoOrConferencingApp = (app: App) =>
|
||||
app?.categories.includes(AppCategories.video) || app?.categories.includes(AppCategories.conferencing);
|
||||
|
||||
const getRemovedIntegrationNameFromAppSlug = (slug: string) =>
|
||||
slug === "msteams" ? "office365_video" : slug.split("-")[0];
|
||||
|
||||
const locationsSchema = z.array(z.object({ type: z.string() }));
|
||||
type TlocationsSchema = z.infer<typeof locationsSchema>;
|
||||
|
||||
export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOptions) => {
|
||||
const { user } = ctx;
|
||||
const { id, teamId } = input;
|
||||
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
id: id,
|
||||
...(teamId ? { teamId } : { userId: ctx.user.id }),
|
||||
},
|
||||
select: {
|
||||
...credentialForCalendarServiceSelect,
|
||||
app: {
|
||||
select: {
|
||||
slug: true,
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!credential) {
|
||||
throw new TRPCError({ code: "NOT_FOUND" });
|
||||
}
|
||||
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
...(teamId ? { teamId } : { userId: ctx.user.id }),
|
||||
},
|
||||
// for managed events
|
||||
{
|
||||
parent: {
|
||||
teamId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
locations: true,
|
||||
destinationCalendar: {
|
||||
include: {
|
||||
credential: true,
|
||||
},
|
||||
},
|
||||
price: true,
|
||||
currency: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Improve this uninstallation cleanup per event by keeping a relation of EventType to App which has the data.
|
||||
for (const eventType of eventTypes) {
|
||||
// If it's a video, replace the location with Cal video
|
||||
if (eventType.locations && isVideoOrConferencingApp(credential.app)) {
|
||||
// Find the user's event types
|
||||
|
||||
const integrationQuery = getRemovedIntegrationNameFromAppSlug(credential.app?.slug ?? "");
|
||||
|
||||
// Check if the event type uses the deleted integration
|
||||
|
||||
// To avoid type errors, need to stringify and parse JSON to use array methods
|
||||
const locations = locationsSchema.parse(eventType.locations);
|
||||
|
||||
const doesDailyVideoAlreadyExists = locations.some((location) =>
|
||||
location.type.includes(DailyLocationType)
|
||||
);
|
||||
|
||||
const updatedLocations: TlocationsSchema = locations.reduce((acc: TlocationsSchema, location) => {
|
||||
if (location.type.includes(integrationQuery)) {
|
||||
if (!doesDailyVideoAlreadyExists) acc.push({ type: DailyLocationType });
|
||||
} else {
|
||||
acc.push(location);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
locations: updatedLocations,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If it's a calendar, remove the destination calendar from the event type
|
||||
if (
|
||||
credential.app?.categories.includes(AppCategories.calendar) &&
|
||||
eventType.destinationCalendar?.credential?.appId === credential.appId
|
||||
) {
|
||||
const destinationCalendar = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
id: eventType.destinationCalendar?.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (destinationCalendar) {
|
||||
await prisma.destinationCalendar.delete({
|
||||
where: {
|
||||
id: destinationCalendar.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (credential.app?.categories.includes(AppCategories.crm)) {
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
const appSlugToDelete = credential.app?.slug;
|
||||
|
||||
if (appSlugToDelete) {
|
||||
const appMetadata = removeAppFromEventTypeMetadata(appSlugToDelete, metadata);
|
||||
|
||||
await prisma.$transaction(async () => {
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
hidden: true,
|
||||
metadata: {
|
||||
...metadata,
|
||||
apps: {
|
||||
...appMetadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If it's a payment, hide the event type and set the price to 0. Also cancel all pending bookings
|
||||
if (credential.app?.categories.includes(AppCategories.payment)) {
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
const appSlug = credential.app?.slug;
|
||||
if (appSlug) {
|
||||
const appMetadata = removeAppFromEventTypeMetadata(appSlug, metadata);
|
||||
|
||||
await prisma.$transaction(async () => {
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
hidden: true,
|
||||
metadata: {
|
||||
...metadata,
|
||||
apps: {
|
||||
...appMetadata,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Assuming that all bookings under this eventType need to be paid
|
||||
const unpaidBookings = await prisma.booking.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: "PENDING",
|
||||
paid: false,
|
||||
payment: {
|
||||
every: {
|
||||
success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
...bookingMinimalSelect,
|
||||
recurringEventId: true,
|
||||
userId: true,
|
||||
responses: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
name: true,
|
||||
destinationCalendar: true,
|
||||
locale: true,
|
||||
},
|
||||
},
|
||||
location: true,
|
||||
references: {
|
||||
select: {
|
||||
uid: true,
|
||||
type: true,
|
||||
externalCalendarId: true,
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
paid: true,
|
||||
eventType: {
|
||||
select: {
|
||||
recurringEvent: true,
|
||||
title: true,
|
||||
bookingFields: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
eventName: true,
|
||||
},
|
||||
},
|
||||
uid: true,
|
||||
eventTypeId: true,
|
||||
destinationCalendar: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const booking of unpaidBookings) {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
cancellationReason: "Payment method removed",
|
||||
},
|
||||
});
|
||||
|
||||
for (const payment of booking.payment) {
|
||||
try {
|
||||
await deletePayment(payment.id, credential);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
await prisma.payment.delete({
|
||||
where: {
|
||||
id: payment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.attendee.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.bookingReference.deleteMany({
|
||||
where: {
|
||||
bookingId: booking.id,
|
||||
},
|
||||
});
|
||||
|
||||
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 tOrganizer = await getTranslation(booking?.user?.locale ?? "en", "common");
|
||||
await sendCancelledEmails(
|
||||
{
|
||||
type: booking?.eventType?.title as string,
|
||||
title: booking.title,
|
||||
description: booking.description,
|
||||
customInputs: isPrismaObjOrUndefined(booking.customInputs),
|
||||
...getCalEventResponses({
|
||||
bookingFields: booking.eventType?.bookingFields ?? null,
|
||||
booking,
|
||||
}),
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
email: booking?.userPrimaryEmail ?? (booking?.user?.email as string),
|
||||
name: booking?.user?.name ?? "Nameless",
|
||||
timeZone: booking?.user?.timeZone as string,
|
||||
language: { translate: tOrganizer, locale: booking?.user?.locale ?? "en" },
|
||||
},
|
||||
attendees: attendeesList,
|
||||
uid: booking.uid,
|
||||
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
|
||||
location: booking.location,
|
||||
destinationCalendar: booking.destinationCalendar
|
||||
? [booking.destinationCalendar]
|
||||
: booking.user?.destinationCalendar
|
||||
? [booking.user?.destinationCalendar]
|
||||
: [],
|
||||
cancellationReason: "Payment method removed by organizer",
|
||||
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
|
||||
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
|
||||
},
|
||||
{
|
||||
eventName: booking?.eventType?.eventName,
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
appStoreMetadata[credential.app?.slug as keyof typeof appStoreMetadata]?.extendsFeature === "EventType"
|
||||
) {
|
||||
const metadata = EventTypeMetaDataSchema.parse(eventType.metadata);
|
||||
const appSlug = credential.app?.slug;
|
||||
if (appSlug) {
|
||||
await prisma.eventType.update({
|
||||
where: {
|
||||
id: eventType.id,
|
||||
},
|
||||
data: {
|
||||
hidden: true,
|
||||
metadata: {
|
||||
...metadata,
|
||||
apps: {
|
||||
...metadata?.apps,
|
||||
[appSlug]: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if zapier get disconnected, delete zapier apiKey, delete zapier webhooks and cancel all scheduled jobs from zapier
|
||||
if (credential.app?.slug === "zapier") {
|
||||
await prisma.apiKey.deleteMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
appId: "zapier",
|
||||
},
|
||||
});
|
||||
await prisma.webhook.deleteMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
appId: "zapier",
|
||||
},
|
||||
});
|
||||
|
||||
deleteWebhookScheduledTriggers({
|
||||
appId: credential.appId,
|
||||
userId: teamId ? undefined : ctx.user.id,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
|
||||
let metadata = userMetadata.parse(user.metadata);
|
||||
|
||||
if (credential.app?.slug === metadata?.defaultConferencingApp?.appSlug) {
|
||||
metadata = {
|
||||
...metadata,
|
||||
defaultConferencingApp: undefined,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
metadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Backwards compatibility. Selected calendars cascade on delete when deleting a credential
|
||||
// If it's a calendar remove it from the SelectedCalendars
|
||||
if (credential.app?.categories.includes(AppCategories.calendar)) {
|
||||
try {
|
||||
const calendar = await getCalendar(credential);
|
||||
|
||||
const calendars = await calendar?.listCalendars();
|
||||
|
||||
const calendarIds = calendars?.map((cal) => cal.externalId);
|
||||
|
||||
await prisma.selectedCalendar.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
integration: credential.type as string,
|
||||
externalId: {
|
||||
in: calendarIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Error deleting selected calendars for userId: ${user.id} integration: ${credential.type}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validated that credential is user's above
|
||||
await prisma.credential.delete({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeAppFromEventTypeMetadata = (
|
||||
appSlugToDelete: string,
|
||||
eventTypeMetadata: z.infer<typeof EventTypeMetaDataSchema>
|
||||
) => {
|
||||
const appMetadata = eventTypeMetadata?.apps
|
||||
? Object.entries(eventTypeMetadata.apps).reduce((filteredApps, [appName, appData]) => {
|
||||
if (appName !== appSlugToDelete) {
|
||||
filteredApps[appName as keyof typeof eventTypeMetadata.apps] = appData;
|
||||
}
|
||||
return filteredApps;
|
||||
}, {} as z.infer<typeof EventTypeAppMetadataSchema>)
|
||||
: {};
|
||||
|
||||
return appMetadata;
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDeleteCredentialInputSchema = z.object({
|
||||
id: z.number(),
|
||||
externalId: z.string().optional(),
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TDeleteCredentialInputSchema = z.infer<typeof ZDeleteCredentialInputSchema>;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
|
||||
import { deleteUser } from "@calcom/features/users/lib/userDeletionService";
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TDeleteMeInputSchema } from "./deleteMe.schema";
|
||||
|
||||
type DeleteMeOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteMeInputSchema;
|
||||
};
|
||||
|
||||
export const deleteMeHandler = async ({ ctx, input }: DeleteMeOptions) => {
|
||||
// TODO: First check password is part of input and meets requirements.
|
||||
|
||||
// Check if input.password is correct
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: ctx.user.email.toLowerCase(),
|
||||
},
|
||||
select: {
|
||||
identityProvider: true,
|
||||
twoFactorEnabled: true,
|
||||
twoFactorSecret: true,
|
||||
password: true,
|
||||
email: true,
|
||||
metadata: true,
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new HttpError({ statusCode: 404, message: ErrorCode.UserNotFound });
|
||||
}
|
||||
|
||||
if (user.identityProvider !== IdentityProvider.CAL) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.ThirdPartyIdentityProviderEnabled });
|
||||
}
|
||||
|
||||
if (!user.password?.hash) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.UserMissingPassword });
|
||||
}
|
||||
|
||||
const isCorrectPassword = await verifyPassword(input.password, user.password.hash);
|
||||
if (!isCorrectPassword) {
|
||||
throw new HttpError({ statusCode: 403, message: ErrorCode.IncorrectPassword });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
if (!input.totpCode) {
|
||||
throw new HttpError({ statusCode: 400, message: 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 input.totpCode is correct
|
||||
const isValidToken = totpAuthenticatorCheck(input.totpCode, secret);
|
||||
if (!isValidToken) {
|
||||
throw new HttpError({ statusCode: 403, message: ErrorCode.IncorrectTwoFactorCode });
|
||||
}
|
||||
}
|
||||
|
||||
await deleteUser(user);
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDeleteMeInputSchema = z.object({
|
||||
password: z.string(),
|
||||
totpCode: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TDeleteMeInputSchema = z.infer<typeof ZDeleteMeInputSchema>;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { deleteStripeCustomer } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import { deleteWebUser as syncServicesDeleteWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type DeleteMeWithoutPasswordOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteMeWithoutPasswordHandler = async ({ ctx }: DeleteMeWithoutPasswordOptions) => {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: ctx.user.email.toLowerCase(),
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new Error(ErrorCode.UserNotFound);
|
||||
}
|
||||
|
||||
if (user.identityProvider === IdentityProvider.CAL) {
|
||||
throw new Error(ErrorCode.SocialIdentityProviderRequired);
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
throw new Error(ErrorCode.SocialIdentityProviderRequired);
|
||||
}
|
||||
|
||||
// Remove me from Stripe
|
||||
await deleteStripeCustomer(user).catch(console.warn);
|
||||
|
||||
// Remove my account
|
||||
const deletedUser = await prisma.user.delete({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
});
|
||||
// Sync Services
|
||||
syncServicesDeleteWebUser(deletedUser);
|
||||
|
||||
return;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TEventTypeOrderInputSchema } from "./eventTypeOrder.schema";
|
||||
|
||||
type EventTypeOrderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TEventTypeOrderInputSchema;
|
||||
};
|
||||
|
||||
export const eventTypeOrderHandler = async ({ ctx, input }: EventTypeOrderOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const allEventTypes = await prisma.eventType.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
id: {
|
||||
in: input.ids,
|
||||
},
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
users: {
|
||||
some: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const allEventTypeIds = new Set(allEventTypes.map((type) => type.id));
|
||||
if (input.ids.some((id) => !allEventTypeIds.has(id))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
await Promise.all(
|
||||
input.ids.reverse().map((id, position) => {
|
||||
return prisma.eventType.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
position,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZEventTypeOrderInputSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TEventTypeOrderInputSchema = z.infer<typeof ZEventTypeOrderInputSchema>;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schema";
|
||||
|
||||
type GetCalVideoRecordingsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TGetCalVideoRecordingsInputSchema;
|
||||
};
|
||||
|
||||
export const getCalVideoRecordingsHandler = async ({ ctx: _ctx, input }: GetCalVideoRecordingsOptions) => {
|
||||
const { roomName } = input;
|
||||
|
||||
try {
|
||||
const res = await getRecordingsOfCalVideoByRoomName(roomName);
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetCalVideoRecordingsInputSchema = z.object({
|
||||
roomName: z.string(),
|
||||
});
|
||||
|
||||
export type TGetCalVideoRecordingsInputSchema = z.infer<typeof ZGetCalVideoRecordingsInputSchema>;
|
||||
@@ -0,0 +1,38 @@
|
||||
/// <reference types="@calcom/types/next-auth" />
|
||||
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { WithSession } from "../../createContext";
|
||||
import type { TGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
|
||||
|
||||
type GetDownloadLinkOfCalVideoRecordingsHandlerOptions = {
|
||||
ctx: WithSession;
|
||||
input: TGetDownloadLinkOfCalVideoRecordingsInputSchema;
|
||||
};
|
||||
|
||||
export const getDownloadLinkOfCalVideoRecordingsHandler = async ({
|
||||
input,
|
||||
ctx,
|
||||
}: GetDownloadLinkOfCalVideoRecordingsHandlerOptions) => {
|
||||
const { recordingId } = input;
|
||||
const { session } = ctx;
|
||||
|
||||
const isDownloadAllowed = IS_SELF_HOSTED || session?.user?.belongsToActiveTeam;
|
||||
|
||||
if (!isDownloadAllowed) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
|
||||
return res;
|
||||
} catch (err) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetDownloadLinkOfCalVideoRecordingsInputSchema = z.object({
|
||||
recordingId: z.string(),
|
||||
});
|
||||
|
||||
export type TGetDownloadLinkOfCalVideoRecordingsInputSchema = z.infer<
|
||||
typeof ZGetDownloadLinkOfCalVideoRecordingsInputSchema
|
||||
>;
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type GetTravelSchedulesOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const getTravelSchedulesHandler = async ({ ctx }: GetTravelSchedulesOptions) => {
|
||||
const allTravelSchedules = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
timeZone: true,
|
||||
},
|
||||
});
|
||||
|
||||
return allTravelSchedules;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { checkIfOrgNeedsUpgradeHandler } from "../viewer/organizations/checkIfOrgNeedsUpgrade.handler";
|
||||
import { getUpgradeableHandler } from "../viewer/teams/getUpgradeable.handler";
|
||||
import { checkInvalidAppCredentials } from "./checkForInvalidAppCredentials";
|
||||
import { shouldVerifyEmailHandler } from "./shouldVerifyEmail.handler";
|
||||
|
||||
type Props = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
const checkInvalidGoogleCalendarCredentials = async ({ ctx }: Props) => {
|
||||
const userCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
type: "google_calendar",
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
ctx.user.selectedCalendars,
|
||||
ctx.user.destinationCalendar?.externalId
|
||||
);
|
||||
|
||||
return connectedCalendars.some((calendar) => !!calendar.error);
|
||||
};
|
||||
|
||||
export const getUserTopBannersHandler = async ({ ctx }: Props) => {
|
||||
const upgradeableTeamMememberships = getUpgradeableHandler({ userId: ctx.user.id });
|
||||
const upgradeableOrgMememberships = checkIfOrgNeedsUpgradeHandler({ ctx });
|
||||
const shouldEmailVerify = shouldVerifyEmailHandler({ ctx });
|
||||
const isInvalidCalendarCredential = checkInvalidGoogleCalendarCredentials({ ctx });
|
||||
const appsWithInavlidCredentials = checkInvalidAppCredentials({ ctx });
|
||||
|
||||
const [
|
||||
teamUpgradeBanner,
|
||||
orgUpgradeBanner,
|
||||
verifyEmailBanner,
|
||||
calendarCredentialBanner,
|
||||
invalidAppCredentialBanners,
|
||||
] = await Promise.allSettled([
|
||||
upgradeableTeamMememberships,
|
||||
upgradeableOrgMememberships,
|
||||
shouldEmailVerify,
|
||||
isInvalidCalendarCredential,
|
||||
appsWithInavlidCredentials,
|
||||
]);
|
||||
|
||||
return {
|
||||
teamUpgradeBanner: teamUpgradeBanner.status === "fulfilled" ? teamUpgradeBanner.value : [],
|
||||
orgUpgradeBanner: orgUpgradeBanner.status === "fulfilled" ? orgUpgradeBanner.value : [],
|
||||
verifyEmailBanner: verifyEmailBanner.status === "fulfilled" ? !verifyEmailBanner.value.isVerified : false,
|
||||
calendarCredentialBanner:
|
||||
calendarCredentialBanner.status === "fulfilled" ? calendarCredentialBanner.value : false,
|
||||
invalidAppCredentialBanners:
|
||||
invalidAppCredentialBanners.status === "fulfilled" ? invalidAppCredentialBanners.value : [],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type GetUsersDefaultConferencingAppOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const getUsersDefaultConferencingAppHandler = async ({
|
||||
ctx,
|
||||
}: GetUsersDefaultConferencingAppOptions) => {
|
||||
return userMetadata.parse(ctx.user.metadata)?.defaultConferencingApp;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import appStore from "@calcom/app-store";
|
||||
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
|
||||
import type { CredentialOwner } from "@calcom/app-store/types";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import getEnabledAppsFromCredentials from "@calcom/lib/apps/getEnabledAppsFromCredentials";
|
||||
import getInstallCountPerApp from "@calcom/lib/apps/getInstallCountPerApp";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
import type { CredentialPayload } from "@calcom/types/Credential";
|
||||
import type { PaymentApp } from "@calcom/types/PaymentService";
|
||||
|
||||
import type { TIntegrationsInputSchema } from "./integrations.schema";
|
||||
|
||||
type IntegrationsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TIntegrationsInputSchema;
|
||||
};
|
||||
|
||||
type TeamQuery = Prisma.TeamGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
credentials: {
|
||||
select: typeof import("@calcom/prisma/selects/credential").credentialForCalendarServiceSelect;
|
||||
};
|
||||
name: true;
|
||||
logoUrl: true;
|
||||
members: {
|
||||
select: {
|
||||
role: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
// type TeamQueryWithParent = TeamQuery & {
|
||||
// parent?: TeamQuery | null;
|
||||
// };
|
||||
|
||||
export const integrationsHandler = async ({ ctx, input }: IntegrationsOptions) => {
|
||||
const { user } = ctx;
|
||||
const {
|
||||
variant,
|
||||
exclude,
|
||||
onlyInstalled,
|
||||
includeTeamInstalledApps,
|
||||
extendsFeature,
|
||||
teamId,
|
||||
sortByMostPopular,
|
||||
appId,
|
||||
} = input;
|
||||
let credentials = await getUsersCredentials(user);
|
||||
let userTeams: TeamQuery[] = [];
|
||||
|
||||
if (includeTeamInstalledApps || teamId) {
|
||||
const teamsQuery = await prisma.team.findMany({
|
||||
where: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
parent: {
|
||||
select: {
|
||||
id: true,
|
||||
credentials: {
|
||||
select: credentialForCalendarServiceSelect,
|
||||
},
|
||||
name: true,
|
||||
logoUrl: true,
|
||||
members: {
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
// If a team is a part of an org then include those apps
|
||||
// Don't want to iterate over these parent teams
|
||||
const filteredTeams: TeamQuery[] = [];
|
||||
const parentTeams: TeamQuery[] = [];
|
||||
// Only loop and grab parent teams if a teamId was given. If not then all teams will be queried
|
||||
if (teamId) {
|
||||
teamsQuery.forEach((team) => {
|
||||
if (team?.parent) {
|
||||
const { parent, ...filteredTeam } = team;
|
||||
filteredTeams.push(filteredTeam);
|
||||
// Only add parent team if it's not already in teamsQuery
|
||||
if (!teamsQuery.some((t) => t.id === parent.id)) {
|
||||
parentTeams.push(parent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userTeams = [...teamsQuery, ...parentTeams];
|
||||
|
||||
const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => {
|
||||
return teamApp.credentials ? teamApp.credentials.flat() : [];
|
||||
});
|
||||
if (!includeTeamInstalledApps || teamId) {
|
||||
credentials = teamAppCredentials;
|
||||
} else {
|
||||
credentials = credentials.concat(teamAppCredentials);
|
||||
}
|
||||
}
|
||||
|
||||
const enabledApps = await getEnabledAppsFromCredentials(credentials, {
|
||||
filterOnCredentials: onlyInstalled,
|
||||
...(appId ? { where: { slug: appId } } : {}),
|
||||
});
|
||||
//TODO: Refactor this to pick up only needed fields and prevent more leaking
|
||||
let apps = await Promise.all(
|
||||
enabledApps.map(async ({ credentials: _, credential, key: _2 /* don't leak to frontend */, ...app }) => {
|
||||
const userCredentialIds = credentials.filter((c) => c.appId === app.slug && !c.teamId).map((c) => c.id);
|
||||
const invalidCredentialIds = credentials
|
||||
.filter((c) => c.appId === app.slug && c.invalid)
|
||||
.map((c) => c.id);
|
||||
const teams = await Promise.all(
|
||||
credentials
|
||||
.filter((c) => c.appId === app.slug && c.teamId)
|
||||
.map(async (c) => {
|
||||
const team = userTeams.find((team) => team.id === c.teamId);
|
||||
if (!team) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamId: team.id,
|
||||
name: team.name,
|
||||
logoUrl: team.logoUrl,
|
||||
credentialId: c.id,
|
||||
isAdmin:
|
||||
team.members[0].role === MembershipRole.ADMIN ||
|
||||
team.members[0].role === MembershipRole.OWNER,
|
||||
};
|
||||
})
|
||||
);
|
||||
// type infer as CredentialOwner
|
||||
const credentialOwner: CredentialOwner = {
|
||||
name: user.name,
|
||||
avatar: user.avatar,
|
||||
};
|
||||
|
||||
// We need to know if app is payment type
|
||||
// undefined it means that app don't require app/setup/page
|
||||
let isSetupAlready = undefined;
|
||||
if (credential && app.categories.includes("payment")) {
|
||||
const paymentApp = (await appStore[app.dirName as keyof typeof appStore]?.()) as PaymentApp | null;
|
||||
if (paymentApp && "lib" in paymentApp && paymentApp?.lib && "PaymentService" in paymentApp?.lib) {
|
||||
const PaymentService = paymentApp.lib.PaymentService;
|
||||
const paymentInstance = new PaymentService(credential);
|
||||
isSetupAlready = paymentInstance.isSetupAlready();
|
||||
}
|
||||
}
|
||||
|
||||
let dependencyData: TDependencyData = [];
|
||||
if (app.dependencies?.length) {
|
||||
dependencyData = app.dependencies.map((dependency) => {
|
||||
const dependencyInstalled = enabledApps.some(
|
||||
(dbAppIterator) => dbAppIterator.credentials.length && dbAppIterator.slug === dependency
|
||||
);
|
||||
// If the app marked as dependency is simply deleted from the codebase, we can have the situation where App is marked installed in DB but we couldn't get the app.
|
||||
const dependencyName = getAppFromSlug(dependency)?.name;
|
||||
return { name: dependencyName, installed: dependencyInstalled };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
...(teams.length && {
|
||||
credentialOwner,
|
||||
}),
|
||||
userCredentialIds,
|
||||
invalidCredentialIds,
|
||||
teams,
|
||||
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
|
||||
isSetupAlready,
|
||||
...(app.dependencies && { dependencyData }),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (variant) {
|
||||
// `flatMap()` these work like `.filter()` but infers the types correctly
|
||||
apps = apps
|
||||
// variant check
|
||||
.flatMap((item) => (item.variant.startsWith(variant) ? [item] : []));
|
||||
}
|
||||
|
||||
if (exclude) {
|
||||
// exclusion filter
|
||||
apps = apps.filter((item) => (exclude ? !exclude.includes(item.variant) : true));
|
||||
}
|
||||
|
||||
if (onlyInstalled) {
|
||||
apps = apps.flatMap((item) =>
|
||||
item.userCredentialIds.length > 0 || item.teams.length || item.isGlobal ? [item] : []
|
||||
);
|
||||
}
|
||||
|
||||
if (extendsFeature) {
|
||||
apps = apps
|
||||
.filter((app) => app.extendsFeature?.includes(extendsFeature))
|
||||
.map((app) => ({
|
||||
...app,
|
||||
isInstalled: !!app.userCredentialIds?.length || !!app.teams?.length || app.isGlobal,
|
||||
}));
|
||||
}
|
||||
|
||||
if (sortByMostPopular) {
|
||||
const installCountPerApp = await getInstallCountPerApp();
|
||||
|
||||
// sort the apps array by the most popular apps
|
||||
apps.sort((a, b) => {
|
||||
const aCount = installCountPerApp[a.slug] || 0;
|
||||
const bCount = installCountPerApp[b.slug] || 0;
|
||||
return bCount - aCount;
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items: apps,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppCategories } from "@calcom/prisma/enums";
|
||||
|
||||
export const ZIntegrationsInputSchema = z.object({
|
||||
variant: z.string().optional(),
|
||||
exclude: z.array(z.string()).optional(),
|
||||
onlyInstalled: z.boolean().optional(),
|
||||
includeTeamInstalledApps: z.boolean().optional(),
|
||||
extendsFeature: z.literal("EventType").optional(),
|
||||
teamId: z.union([z.number(), z.null()]).optional(),
|
||||
sortByMostPopular: z.boolean().optional(),
|
||||
categories: z.nativeEnum(AppCategories).array().optional(),
|
||||
appId: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TIntegrationsInputSchema = z.infer<typeof ZIntegrationsInputSchema>;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { getLocationGroupedOptions } from "@calcom/app-store/server";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TLocationOptionsInputSchema } from "./locationOptions.schema";
|
||||
|
||||
type LocationOptionsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TLocationOptionsInputSchema;
|
||||
};
|
||||
|
||||
export const locationOptionsHandler = async ({ ctx, input }: LocationOptionsOptions) => {
|
||||
const { teamId } = input;
|
||||
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
|
||||
const locationOptions = await getLocationGroupedOptions(teamId ? { teamId } : { userId: ctx.user.id }, t);
|
||||
// If it is a team event then move the "use host location" option to top
|
||||
if (input.teamId) {
|
||||
const conferencingIndex = locationOptions.findIndex((option) => option.label === "Conferencing");
|
||||
if (conferencingIndex !== -1) {
|
||||
const conferencingObject = locationOptions.splice(conferencingIndex, 1)[0];
|
||||
locationOptions.unshift(conferencingObject);
|
||||
}
|
||||
}
|
||||
|
||||
return locationOptions;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZLocationOptionsInputSchema = z.object({
|
||||
teamId: z.number().optional(),
|
||||
});
|
||||
|
||||
export type TLocationOptionsInputSchema = z.infer<typeof ZLocationOptionsInputSchema>;
|
||||
157
calcom/packages/trpc/server/routers/loggedInViewer/me.handler.ts
Normal file
157
calcom/packages/trpc/server/routers/loggedInViewer/me.handler.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { ProfileRepository } from "@calcom/lib/server/repository/profile";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TMeInputSchema } from "./me.schema";
|
||||
|
||||
type MeOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
session: Session;
|
||||
};
|
||||
input: TMeInputSchema;
|
||||
};
|
||||
|
||||
export const meHandler = async ({ ctx, input }: MeOptions) => {
|
||||
const crypto = await import("crypto");
|
||||
|
||||
const { user: sessionUser, session } = ctx;
|
||||
|
||||
const allUserEnrichedProfiles = await ProfileRepository.findAllProfilesForUserIncludingMovedUser(
|
||||
sessionUser
|
||||
);
|
||||
|
||||
const user = await UserRepository.enrichUserWithTheProfile({
|
||||
user: sessionUser,
|
||||
upId: session.upId,
|
||||
});
|
||||
|
||||
const secondaryEmails = await prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
let passwordAdded = false;
|
||||
if (user.identityProvider !== IdentityProvider.CAL && input?.includePasswordAdded) {
|
||||
const userWithPassword = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
password: true,
|
||||
},
|
||||
});
|
||||
if (userWithPassword?.password?.hash) {
|
||||
passwordAdded = true;
|
||||
}
|
||||
}
|
||||
|
||||
let identityProviderEmail = "";
|
||||
if (user.identityProviderId) {
|
||||
const account = await prisma.account.findUnique({
|
||||
where: {
|
||||
provider_providerAccountId: {
|
||||
provider: user.identityProvider.toLocaleLowerCase(),
|
||||
providerAccountId: user.identityProviderId,
|
||||
},
|
||||
},
|
||||
select: { providerEmail: true },
|
||||
});
|
||||
identityProviderEmail = account?.providerEmail || "";
|
||||
}
|
||||
|
||||
const additionalUserInfo = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
bookings: {
|
||||
select: { id: true },
|
||||
},
|
||||
selectedCalendars: true,
|
||||
teams: {
|
||||
select: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
eventTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
eventTypes: {
|
||||
select: { id: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
let sumOfTeamEventTypes = 0;
|
||||
for (const team of additionalUserInfo?.teams || []) {
|
||||
for (const _eventType of team.team.eventTypes) {
|
||||
sumOfTeamEventTypes++;
|
||||
}
|
||||
}
|
||||
const userMetadataPrased = userMetadata.parse(user.metadata);
|
||||
|
||||
// Destructuring here only makes it more illegible
|
||||
// pick only the part we want to expose in the API
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailMd5: crypto.createHash("md5").update(user.email).digest("hex"),
|
||||
emailVerified: user.emailVerified,
|
||||
startTime: user.startTime,
|
||||
endTime: user.endTime,
|
||||
bufferTime: user.bufferTime,
|
||||
locale: user.locale,
|
||||
timeFormat: user.timeFormat,
|
||||
timeZone: user.timeZone,
|
||||
avatar: getUserAvatarUrl(user),
|
||||
avatarUrl: user.avatarUrl,
|
||||
createdDate: user.createdDate,
|
||||
trialEndsAt: user.trialEndsAt,
|
||||
defaultScheduleId: user.defaultScheduleId,
|
||||
completedOnboarding: user.completedOnboarding,
|
||||
twoFactorEnabled: user.twoFactorEnabled,
|
||||
disableImpersonation: user.disableImpersonation,
|
||||
identityProvider: user.identityProvider,
|
||||
identityProviderEmail,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
bio: user.bio,
|
||||
weekStart: user.weekStart,
|
||||
theme: user.theme,
|
||||
appTheme: user.appTheme,
|
||||
hideBranding: user.hideBranding,
|
||||
metadata: user.metadata,
|
||||
defaultBookerLayouts: user.defaultBookerLayouts,
|
||||
allowDynamicBooking: user.allowDynamicBooking,
|
||||
allowSEOIndexing: user.allowSEOIndexing,
|
||||
receiveMonthlyDigestEmail: user.receiveMonthlyDigestEmail,
|
||||
organizationId: user.profile?.organizationId ?? null,
|
||||
organization: user.organization,
|
||||
username: user.profile?.username ?? user.username ?? null,
|
||||
profile: user.profile ?? null,
|
||||
profiles: allUserEnrichedProfiles,
|
||||
secondaryEmails,
|
||||
sumOfBookings: additionalUserInfo?.bookings.length,
|
||||
sumOfCalendars: additionalUserInfo?.selectedCalendars.length,
|
||||
sumOfTeams: additionalUserInfo?.teams.length,
|
||||
sumOfEventTypes: additionalUserInfo?.eventTypes.length,
|
||||
isPremium: userMetadataPrased?.isPremium,
|
||||
sumOfTeamEventTypes,
|
||||
...(passwordAdded ? { passwordAdded } : {}),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZMeInputSchema = z
|
||||
.object({
|
||||
includePasswordAdded: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TMeInputSchema = z.infer<typeof ZMeInputSchema>;
|
||||
@@ -0,0 +1,260 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendBookingRedirectNotification } from "@calcom/emails";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TOutOfOfficeDelete, TOutOfOfficeInputSchema } from "./outOfOffice.schema";
|
||||
|
||||
type TBookingRedirect = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TOutOfOfficeInputSchema;
|
||||
};
|
||||
|
||||
export const outOfOfficeCreate = async ({ ctx, input }: TBookingRedirect) => {
|
||||
const { startDate, endDate } = input.dateRange;
|
||||
if (!startDate || !endDate) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_and_end_date_required" });
|
||||
}
|
||||
|
||||
const inputStartTime = dayjs(startDate).startOf("day");
|
||||
const inputEndTime = dayjs(endDate).endOf("day");
|
||||
const offset = dayjs(inputStartTime).utcOffset();
|
||||
|
||||
// If start date is after end date throw error
|
||||
if (inputStartTime.isAfter(inputEndTime)) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_before_end_date" });
|
||||
}
|
||||
|
||||
// If start date is before to today throw error
|
||||
// Since this validation is done using server tz, we need to account for the offset
|
||||
if (
|
||||
inputStartTime.isBefore(
|
||||
dayjs()
|
||||
.startOf("day")
|
||||
.subtract(Math.abs(offset) * 60, "minute")
|
||||
)
|
||||
) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "start_date_must_be_in_the_future" });
|
||||
}
|
||||
|
||||
let toUserId;
|
||||
|
||||
if (input.toTeamUserId) {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: input.toTeamUserId,
|
||||
/** You can only create OOO for members of teams you belong to */
|
||||
teams: {
|
||||
some: {
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: ctx.user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "user_not_found" });
|
||||
}
|
||||
toUserId = user?.id;
|
||||
}
|
||||
|
||||
// Validate if OOO entry for these dates already exists
|
||||
const outOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ userId: ctx.user.id },
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
start: {
|
||||
lt: inputEndTime.toISOString(), //existing start is less than or equal to input end time
|
||||
},
|
||||
end: {
|
||||
gt: inputStartTime.toISOString(), //existing end is greater than or equal to input start time
|
||||
},
|
||||
},
|
||||
{
|
||||
//existing start is within the new input range
|
||||
start: {
|
||||
gt: inputStartTime.toISOString(),
|
||||
lt: inputEndTime.toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
//existing end is within the new input range
|
||||
end: {
|
||||
gt: inputStartTime.toISOString(),
|
||||
lt: inputEndTime.toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// don't allow overlapping entries
|
||||
if (outOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "CONFLICT", message: "out_of_office_entry_already_exists" });
|
||||
}
|
||||
|
||||
if (!input.reasonId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "reason_id_required" });
|
||||
}
|
||||
|
||||
// Prevent infinite redirects but consider time ranges
|
||||
const existingOutOfOfficeEntry = await prisma.outOfOfficeEntry.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
toUserId: true,
|
||||
},
|
||||
where: {
|
||||
userId: toUserId,
|
||||
toUserId: ctx.user.id,
|
||||
// Check for time overlap or collision
|
||||
OR: [
|
||||
// Outside of range
|
||||
{
|
||||
AND: [
|
||||
{ start: { lte: inputEndTime.toISOString() } },
|
||||
{ end: { gte: inputStartTime.toISOString() } },
|
||||
],
|
||||
},
|
||||
// Inside of range
|
||||
{
|
||||
AND: [
|
||||
{ start: { gte: inputStartTime.toISOString() } },
|
||||
{ end: { lte: inputEndTime.toISOString() } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// don't allow infinite redirects
|
||||
if (existingOutOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "booking_redirect_infinite_not_allowed" });
|
||||
}
|
||||
const startDateUtc = dayjs.utc(startDate).add(input.offset, "minute");
|
||||
const endDateUtc = dayjs.utc(endDate).add(input.offset, "minute");
|
||||
|
||||
const createdRedirect = await prisma.outOfOfficeEntry.create({
|
||||
data: {
|
||||
uuid: uuidv4(),
|
||||
start: startDateUtc.startOf("day").toISOString(),
|
||||
end: endDateUtc.endOf("day").toISOString(),
|
||||
notes: input.notes,
|
||||
userId: ctx.user.id,
|
||||
reasonId: input.reasonId,
|
||||
toUserId: toUserId,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
if (toUserId) {
|
||||
// await send email to notify user
|
||||
const userToNotify = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: toUserId,
|
||||
},
|
||||
select: {
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
const t = await getTranslation(ctx.user.locale ?? "en", "common");
|
||||
const formattedStartDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.start);
|
||||
const formattedEndDate = new Intl.DateTimeFormat("en-US").format(createdRedirect.end);
|
||||
if (userToNotify?.email) {
|
||||
await sendBookingRedirectNotification({
|
||||
language: t,
|
||||
fromEmail: ctx.user.email,
|
||||
toEmail: userToNotify.email,
|
||||
toName: ctx.user.username || "",
|
||||
dates: `${formattedStartDate} - ${formattedEndDate}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
type TBookingRedirectDelete = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TOutOfOfficeDelete;
|
||||
};
|
||||
|
||||
export const outOfOfficeEntryDelete = async ({ ctx, input }: TBookingRedirectDelete) => {
|
||||
if (!input.outOfOfficeUid) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "out_of_office_id_required" });
|
||||
}
|
||||
|
||||
const deletedOutOfOfficeEntry = await prisma.outOfOfficeEntry.delete({
|
||||
where: {
|
||||
uuid: input.outOfOfficeUid,
|
||||
/** Validate outOfOfficeEntry belongs to the user deleting it */
|
||||
userId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!deletedOutOfOfficeEntry) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "booking_redirect_not_found" });
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
export const outOfOfficeEntriesList = async ({ ctx }: { ctx: { user: NonNullable<TrpcSessionUser> } }) => {
|
||||
const outOfOfficeEntries = await prisma.outOfOfficeEntry.findMany({
|
||||
where: {
|
||||
userId: ctx.user.id,
|
||||
end: {
|
||||
gte: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uuid: true,
|
||||
start: true,
|
||||
end: true,
|
||||
toUserId: true,
|
||||
toUser: {
|
||||
select: {
|
||||
username: true,
|
||||
},
|
||||
},
|
||||
reason: {
|
||||
select: {
|
||||
id: true,
|
||||
emoji: true,
|
||||
reason: true,
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
notes: true,
|
||||
},
|
||||
});
|
||||
|
||||
return outOfOfficeEntries;
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZOutOfOfficeInputSchema = z.object({
|
||||
dateRange: z.object({
|
||||
startDate: z.date(),
|
||||
endDate: z.date(),
|
||||
}),
|
||||
offset: z.number(),
|
||||
toTeamUserId: z.number().nullable(),
|
||||
reasonId: z.number(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeInputSchema = z.infer<typeof ZOutOfOfficeInputSchema>;
|
||||
|
||||
export const ZOutOfOfficeDelete = z.object({
|
||||
outOfOfficeUid: z.string(),
|
||||
});
|
||||
|
||||
export type TOutOfOfficeDelete = z.infer<typeof ZOutOfOfficeDelete>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const outOfOfficeReasonList = async () => {
|
||||
const outOfOfficeReasons = await prisma.outOfOfficeReason.findMany({
|
||||
where: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
return outOfOfficeReasons;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { ZMeInputSchema } from "../me.schema";
|
||||
|
||||
export const me = authedProcedure.input(ZMeInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = (await import("../me.handler")).meHandler;
|
||||
|
||||
return handler({ ctx, input });
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { ZTeamsAndUserProfilesQueryInputSchema } from "../teamsAndUserProfilesQuery.schema";
|
||||
|
||||
export const teamsAndUserProfilesQuery = authedProcedure
|
||||
.input(ZTeamsAndUserProfilesQueryInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const handler = (await import("../teamsAndUserProfilesQuery.handler")).teamsAndUserProfilesQuery;
|
||||
|
||||
return handler({ ctx, input });
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
|
||||
|
||||
type RoutingFormOrderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TRoutingFormOrderInputSchema;
|
||||
};
|
||||
|
||||
export const routingFormOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const forms = await prisma.app_RoutingForms_Form.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
responses: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const allFormIds = new Set(forms.map((form) => form.id));
|
||||
if (input.ids.some((id) => !allFormIds.has(id))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.ids.reverse().map((id, position) => {
|
||||
return prisma.app_RoutingForms_Form.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
position,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZRoutingFormOrderInputSchema = z.object({
|
||||
ids: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TRoutingFormOrderInputSchema = z.infer<typeof ZRoutingFormOrderInputSchema>;
|
||||
@@ -0,0 +1,77 @@
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
||||
|
||||
type SessionUser = NonNullable<TrpcSessionUser>;
|
||||
type User = {
|
||||
id: SessionUser["id"];
|
||||
selectedCalendars: SessionUser["selectedCalendars"];
|
||||
};
|
||||
|
||||
type SetDestinationCalendarOptions = {
|
||||
ctx: {
|
||||
user: User;
|
||||
};
|
||||
input: TSetDestinationCalendarInputSchema;
|
||||
};
|
||||
|
||||
export const setDestinationCalendarHandler = async ({ ctx, input }: SetDestinationCalendarOptions) => {
|
||||
const { user } = ctx;
|
||||
const { integration, externalId, eventTypeId } = input;
|
||||
const credentials = await getUsersCredentials(user);
|
||||
const calendarCredentials = getCalendarCredentials(credentials);
|
||||
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
|
||||
const allCals = connectedCalendars.map((cal) => cal.calendars ?? []).flat();
|
||||
|
||||
const credentialId = allCals.find(
|
||||
(cal) => cal.externalId === externalId && cal.integration === integration && cal.readOnly === false
|
||||
)?.credentialId;
|
||||
|
||||
if (!credentialId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: `Could not find calendar ${input.externalId}` });
|
||||
}
|
||||
|
||||
const primaryEmail = allCals.find((cal) => cal.primary && cal.credentialId === credentialId)?.email ?? null;
|
||||
|
||||
let where;
|
||||
|
||||
if (eventTypeId) {
|
||||
if (
|
||||
!(await prisma.eventType.findFirst({
|
||||
where: {
|
||||
id: eventTypeId,
|
||||
userId: user.id,
|
||||
},
|
||||
}))
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: `You don't have access to event type ${eventTypeId}`,
|
||||
});
|
||||
}
|
||||
|
||||
where = { eventTypeId };
|
||||
} else where = { userId: user.id };
|
||||
|
||||
await prisma.destinationCalendar.upsert({
|
||||
where,
|
||||
update: {
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
primaryEmail,
|
||||
},
|
||||
create: {
|
||||
...where,
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
primaryEmail,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZSetDestinationCalendarInputSchema = z.object({
|
||||
integration: z.string(),
|
||||
externalId: z.string(),
|
||||
eventTypeId: z.number().nullish(),
|
||||
bookingId: z.number().nullish(),
|
||||
});
|
||||
|
||||
export type TSetDestinationCalendarInputSchema = z.infer<typeof ZSetDestinationCalendarInputSchema>;
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type ShouldVerifyEmailType = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const shouldVerifyEmailHandler = async ({ ctx }: ShouldVerifyEmailType) => {
|
||||
const { user } = ctx;
|
||||
const isVerified = !!user.emailVerified;
|
||||
const isCalProvider = user.identityProvider === "CAL"; // We dont need to verify on OAUTH providers as they are already verified by the provider
|
||||
|
||||
const obj = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
isVerified: isVerified || !isCalProvider,
|
||||
};
|
||||
|
||||
return obj;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,67 @@
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
type StripeCustomerOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const stripeCustomerHandler = async ({ ctx }: StripeCustomerOptions) => {
|
||||
const {
|
||||
user: { id: userId },
|
||||
} = ctx;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "User not found" });
|
||||
}
|
||||
|
||||
const metadata = userMetadata.parse(user.metadata);
|
||||
let stripeCustomerId = metadata?.stripeCustomerId;
|
||||
if (!stripeCustomerId) {
|
||||
// Create stripe customer
|
||||
const customer = await stripe.customers.create({
|
||||
metadata: {
|
||||
userId: userId.toString(),
|
||||
},
|
||||
});
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
...metadata,
|
||||
stripeCustomerId: customer.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
stripeCustomerId = customer.id;
|
||||
}
|
||||
|
||||
// Fetch stripe customer
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
if (customer.deleted) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "No stripe customer found" });
|
||||
}
|
||||
|
||||
const username = customer?.metadata?.username || null;
|
||||
|
||||
return {
|
||||
isPremium: !!metadata?.isPremium,
|
||||
username,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,38 @@
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendFeedbackEmail } from "@calcom/emails";
|
||||
import { sendFeedbackFormbricks } from "@calcom/lib/formbricks";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import type { TSubmitFeedbackInputSchema } from "./submitFeedback.schema";
|
||||
|
||||
type SubmitFeedbackOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TSubmitFeedbackInputSchema;
|
||||
};
|
||||
|
||||
export const submitFeedbackHandler = async ({ ctx, input }: SubmitFeedbackOptions) => {
|
||||
const { rating, comment } = input;
|
||||
|
||||
const feedback = {
|
||||
username: ctx.user.username || "Nameless",
|
||||
email: ctx.user.email || "No email address",
|
||||
rating: rating,
|
||||
comment: comment,
|
||||
};
|
||||
|
||||
await prisma.feedback.create({
|
||||
data: {
|
||||
date: dayjs().toISOString(),
|
||||
userId: ctx.user.id,
|
||||
rating: rating,
|
||||
comment: comment,
|
||||
},
|
||||
});
|
||||
if (process.env.NEXT_PUBLIC_FORMBRICKS_HOST_URL && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID)
|
||||
sendFeedbackFormbricks(ctx.user.id, feedback);
|
||||
|
||||
if (process.env.SEND_FEEDBACK_EMAIL && comment) sendFeedbackEmail(feedback);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZSubmitFeedbackInputSchema = z.object({
|
||||
rating: z.string(),
|
||||
comment: z.string(),
|
||||
});
|
||||
|
||||
export type TSubmitFeedbackInputSchema = z.infer<typeof ZSubmitFeedbackInputSchema>;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { withRoleCanCreateEntity } from "@calcom/lib/entityPermissionUtils";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TTeamsAndUserProfilesQueryInputSchema } from "./teamsAndUserProfilesQuery.schema";
|
||||
|
||||
type TeamsAndUserProfileOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TTeamsAndUserProfilesQueryInputSchema;
|
||||
};
|
||||
|
||||
export const teamsAndUserProfilesQuery = async ({ ctx, input }: TeamsAndUserProfileOptions) => {
|
||||
const { prisma } = ctx;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
select: {
|
||||
avatarUrl: true,
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
isOrganization: true,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
metadata: true,
|
||||
parentId: true,
|
||||
members: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
}
|
||||
|
||||
let teamsData;
|
||||
|
||||
if (input?.includeOrg) {
|
||||
teamsData = user.teams.map((membership) => ({
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
teamsData = user.teams
|
||||
.filter((membership) => !membership.team.isOrganization)
|
||||
.map((membership) => ({
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
teamId: null,
|
||||
name: user.name,
|
||||
slug: user.username,
|
||||
image: getUserAvatarUrl({
|
||||
avatarUrl: user.avatarUrl,
|
||||
}),
|
||||
readOnly: false,
|
||||
},
|
||||
...teamsData.map((membership) => ({
|
||||
teamId: membership.team.id,
|
||||
name: membership.team.name,
|
||||
slug: membership.team.slug ? `team/${membership.team.slug}` : null,
|
||||
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
|
||||
role: membership.role,
|
||||
readOnly: !withRoleCanCreateEntity(membership.role),
|
||||
})),
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZTeamsAndUserProfilesQueryInputSchema = z
|
||||
.object({
|
||||
includeOrg: z.boolean().optional(),
|
||||
})
|
||||
.optional();
|
||||
|
||||
export type TTeamsAndUserProfilesQueryInputSchema = z.infer<typeof ZTeamsAndUserProfilesQueryInputSchema>;
|
||||
@@ -0,0 +1,71 @@
|
||||
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
|
||||
import unlinkConnectedAccountHandler from "./unlinkConnectedAccount.handler";
|
||||
|
||||
const buildOrgMockData = () => ({ id: null, isOrgAdmin: false, metadata: {}, requestedSlug: null });
|
||||
|
||||
const buildProfileMockData = () => ({
|
||||
username: "test",
|
||||
upId: "usr-xx",
|
||||
id: null,
|
||||
organizationId: null,
|
||||
organization: null,
|
||||
name: "Test User",
|
||||
avatarUrl: null,
|
||||
startTime: 0,
|
||||
endTime: 1440,
|
||||
bufferTime: 0,
|
||||
});
|
||||
|
||||
async function buildMockData(
|
||||
identityProvider: IdentityProvider = IdentityProvider.GOOGLE,
|
||||
identityProviderId: string | null = null
|
||||
) {
|
||||
const promise = await prismock.user.create({
|
||||
data: {
|
||||
id: 1,
|
||||
username: "test",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
identityProvider,
|
||||
identityProviderId,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await promise;
|
||||
return {
|
||||
...user,
|
||||
organization: buildOrgMockData(),
|
||||
defaultBookerLayouts: null,
|
||||
selectedCalendars: [],
|
||||
destinationCalendar: null,
|
||||
profile: buildProfileMockData(),
|
||||
avatar: "",
|
||||
locale: "en",
|
||||
};
|
||||
}
|
||||
|
||||
describe("unlinkConnectedAccount.handler", () => {
|
||||
it("Should response with a success message when unlinking an Google account", async () => {
|
||||
const user = await buildMockData(IdentityProvider.GOOGLE, "123456789012345678901");
|
||||
const response = await unlinkConnectedAccountHandler({ ctx: { user } });
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
{
|
||||
"message": "account_unlinked_success",
|
||||
}
|
||||
`);
|
||||
});
|
||||
it("Should respond with an error message if unlink was unsucessful", async () => {
|
||||
const user = await buildMockData(IdentityProvider.CAL);
|
||||
const response = await unlinkConnectedAccountHandler({ ctx: { user } });
|
||||
expect(response).toMatchInlineSnapshot(`
|
||||
{
|
||||
"message": "account_unlinked_error",
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { IdentityProvider } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
type UpdateProfileOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
res?: NextApiResponse | GetServerSidePropsContext["res"];
|
||||
};
|
||||
};
|
||||
|
||||
const unlinkConnectedAccount = async ({ ctx }: UpdateProfileOptions) => {
|
||||
const { user } = ctx;
|
||||
// Unlink the account
|
||||
const CalComAdapter = (await import("@calcom/features/auth/lib/next-auth-custom-adapter")).default;
|
||||
const calcomAdapter = CalComAdapter(prisma);
|
||||
// If it fails to delete, don't stop because the users login data might not be present
|
||||
try {
|
||||
await calcomAdapter.unlinkAccount({
|
||||
provider: user.identityProvider.toLocaleLowerCase(),
|
||||
providerAccountId: user.identityProviderId || "",
|
||||
});
|
||||
} catch {
|
||||
// Fail silenty if we don't have an record in the account table
|
||||
}
|
||||
// Fall back to the default identity provider
|
||||
const _user = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
identityProvider: IdentityProvider.GOOGLE,
|
||||
identityProviderId: { not: null },
|
||||
},
|
||||
data: {
|
||||
identityProvider: IdentityProvider.CAL,
|
||||
identityProviderId: null,
|
||||
},
|
||||
});
|
||||
if (!_user) return { message: "account_unlinked_error" };
|
||||
return { message: "account_unlinked_success" };
|
||||
};
|
||||
|
||||
export default unlinkConnectedAccount;
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { keyBy } from "lodash";
|
||||
import type { GetServerSidePropsContext, NextApiResponse } from "next";
|
||||
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { getPremiumMonthlyPlanPriceId } from "@calcom/app-store/stripepayment/lib/utils";
|
||||
import { sendChangeOfEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
|
||||
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server";
|
||||
import { uploadAvatar } from "@calcom/lib/server/avatar";
|
||||
import { checkUsername } from "@calcom/lib/server/checkUsername";
|
||||
import { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
|
||||
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { updateWebUser as syncServicesUpdateWebUser } from "@calcom/lib/sync/SyncServiceManager";
|
||||
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { getDefaultScheduleId } from "../viewer/availability/util";
|
||||
import { updateUserMetadataAllowedKeys, type TUpdateProfileInputSchema } from "./updateProfile.schema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["updateProfile"] });
|
||||
type UpdateProfileOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
res?: NextApiResponse | GetServerSidePropsContext["res"];
|
||||
};
|
||||
input: TUpdateProfileInputSchema;
|
||||
};
|
||||
|
||||
export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) => {
|
||||
const { user } = ctx;
|
||||
const userMetadata = handleUserMetadata({ ctx, input });
|
||||
const locale = input.locale || user.locale;
|
||||
const emailVerification = await getFeatureFlag(prisma, "email-verification");
|
||||
|
||||
const { travelSchedules, ...rest } = input;
|
||||
|
||||
const secondaryEmails = input?.secondaryEmails || [];
|
||||
delete input.secondaryEmails;
|
||||
|
||||
const data: Prisma.UserUpdateInput = {
|
||||
...rest,
|
||||
metadata: userMetadata,
|
||||
secondaryEmails: undefined,
|
||||
};
|
||||
|
||||
let isPremiumUsername = false;
|
||||
|
||||
const layoutError = validateBookerLayouts(input?.metadata?.defaultBookerLayouts || null);
|
||||
if (layoutError) {
|
||||
const t = await getTranslation(locale, "common");
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: t(layoutError) });
|
||||
}
|
||||
|
||||
if (input.username && !user.organizationId) {
|
||||
const username = slugify(input.username);
|
||||
// Only validate if we're changing usernames
|
||||
if (username !== user.username) {
|
||||
data.username = username;
|
||||
const response = await checkUsername(username);
|
||||
isPremiumUsername = response.premium;
|
||||
if (!response.available) {
|
||||
const t = await getTranslation(locale, "common");
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: t("username_already_taken") });
|
||||
}
|
||||
}
|
||||
} else if (input.username && user.organizationId && user.movedToProfileId) {
|
||||
// don't change user.username if we have profile.username
|
||||
delete data.username;
|
||||
}
|
||||
|
||||
if (isPremiumUsername) {
|
||||
const stripeCustomerId = userMetadata?.stripeCustomerId;
|
||||
const isPremium = userMetadata?.isPremium;
|
||||
if (!isPremium || !stripeCustomerId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "User is not premium" });
|
||||
}
|
||||
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({ customer: stripeCustomerId });
|
||||
|
||||
if (!stripeSubscriptions || !stripeSubscriptions.data.length) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "No stripeSubscription found",
|
||||
});
|
||||
}
|
||||
|
||||
// Iterate over subscriptions and look for premium product id and status active
|
||||
// @TODO: iterate if stripeSubscriptions.hasMore is true
|
||||
const isPremiumUsernameSubscriptionActive = stripeSubscriptions.data.some(
|
||||
(subscription) =>
|
||||
subscription.items.data[0].price.id === getPremiumMonthlyPlanPriceId() &&
|
||||
subscription.status === "active"
|
||||
);
|
||||
|
||||
if (!isPremiumUsernameSubscriptionActive) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You need to pay for premium username",
|
||||
});
|
||||
}
|
||||
}
|
||||
const hasEmailBeenChanged = data.email && user.email !== data.email;
|
||||
|
||||
let secondaryEmail:
|
||||
| {
|
||||
id: number;
|
||||
emailVerified: Date | null;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
const primaryEmailVerified = user.emailVerified;
|
||||
if (hasEmailBeenChanged) {
|
||||
secondaryEmail = await prisma.secondaryEmail.findUnique({
|
||||
where: {
|
||||
email: input.email,
|
||||
userId: user.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
emailVerified: true,
|
||||
},
|
||||
});
|
||||
if (emailVerification) {
|
||||
if (secondaryEmail?.emailVerified) {
|
||||
data.emailVerified = secondaryEmail.emailVerified;
|
||||
} else {
|
||||
// Set metadata of the user so we can set it to this updated email once it is confirmed
|
||||
data.metadata = {
|
||||
...userMetadata,
|
||||
emailChangeWaitingForVerification: input.email?.toLocaleLowerCase(),
|
||||
};
|
||||
|
||||
// Check to ensure this email isnt in use
|
||||
// Don't include email in the data payload if we need to verify
|
||||
delete data.email;
|
||||
}
|
||||
} else {
|
||||
log.warn("Profile Update - Email verification is disabled - Skipping");
|
||||
data.emailVerified = null;
|
||||
}
|
||||
}
|
||||
|
||||
// if defined AND a base 64 string, upload and update the avatar URL
|
||||
if (input.avatarUrl && input.avatarUrl.startsWith("data:image/png;base64,")) {
|
||||
data.avatarUrl = await uploadAvatar({
|
||||
avatar: await resizeBase64Image(input.avatarUrl),
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.completedOnboarding) {
|
||||
const userTeams = await prisma.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
select: {
|
||||
teams: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (userTeams && userTeams.teams.length > 0) {
|
||||
await Promise.all(
|
||||
userTeams.teams.map(async (team) => {
|
||||
await updateNewTeamMemberEventTypes(user.id, team.id);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (travelSchedules) {
|
||||
const existingSchedules = await prisma.travelSchedule.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const schedulesToDelete = existingSchedules.filter(
|
||||
(schedule) =>
|
||||
!travelSchedules || !travelSchedules.find((scheduleInput) => scheduleInput.id === schedule.id)
|
||||
);
|
||||
|
||||
await prisma.travelSchedule.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
id: {
|
||||
in: schedulesToDelete.map((schedule) => schedule.id) as number[],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.travelSchedule.createMany({
|
||||
data: travelSchedules
|
||||
.filter((schedule) => !schedule.id)
|
||||
.map((schedule) => {
|
||||
return {
|
||||
userId: user.id,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
timeZone: schedule.timeZone,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUserSelect = Prisma.validator<Prisma.UserDefaultArgs>()({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
identityProvider: true,
|
||||
identityProviderId: true,
|
||||
metadata: true,
|
||||
name: true,
|
||||
createdDate: true,
|
||||
avatarUrl: true,
|
||||
locale: true,
|
||||
schedules: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let updatedUser: Prisma.UserGetPayload<typeof updatedUserSelect>;
|
||||
|
||||
try {
|
||||
updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data,
|
||||
...updatedUserSelect,
|
||||
});
|
||||
} catch (e) {
|
||||
// Catch unique constraint failure on email field.
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002") {
|
||||
const meta = e.meta as { target: string[] };
|
||||
if (meta.target.indexOf("email") !== -1) {
|
||||
throw new HttpError({ statusCode: 409, message: "email_already_used" });
|
||||
}
|
||||
}
|
||||
throw e; // make sure other errors are rethrown
|
||||
}
|
||||
|
||||
if (user.timeZone !== data.timeZone && updatedUser.schedules.length > 0) {
|
||||
// on timezone change update timezone of default schedule
|
||||
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: data.timeZone,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Sync Services
|
||||
await syncServicesUpdateWebUser(updatedUser);
|
||||
|
||||
// Notify stripe about the change
|
||||
if (updatedUser && updatedUser.metadata && hasKeyInMetadata(updatedUser, "stripeCustomerId")) {
|
||||
const stripeCustomerId = `${updatedUser.metadata.stripeCustomerId}`;
|
||||
await stripe.customers.update(stripeCustomerId, {
|
||||
metadata: {
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
userId: updatedUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (updatedUser && hasEmailBeenChanged) {
|
||||
// Skip sending verification email when user tries to change his primary email to a verified secondary email
|
||||
if (secondaryEmail?.emailVerified) {
|
||||
secondaryEmails.push({
|
||||
id: secondaryEmail.id,
|
||||
email: user.email,
|
||||
isDeleted: false,
|
||||
});
|
||||
} else {
|
||||
await sendChangeOfEmailVerification({
|
||||
user: {
|
||||
username: updatedUser.username ?? "Nameless User",
|
||||
emailFrom: user.email,
|
||||
// We know email has been changed here so we can use input
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
emailTo: input.email!,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (secondaryEmails.length) {
|
||||
const recordsToDelete = secondaryEmails
|
||||
.filter((secondaryEmail) => secondaryEmail.isDeleted)
|
||||
.map((secondaryEmail) => secondaryEmail.id);
|
||||
if (recordsToDelete.length) {
|
||||
await prisma.secondaryEmail.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: recordsToDelete,
|
||||
},
|
||||
userId: updatedUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const modifiedRecords = secondaryEmails.filter((secondaryEmail) => !secondaryEmail.isDeleted);
|
||||
if (modifiedRecords.length) {
|
||||
const secondaryEmailsFromDB = await prisma.secondaryEmail.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: secondaryEmails.map((secondaryEmail) => secondaryEmail.id),
|
||||
},
|
||||
userId: updatedUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
const keyedSecondaryEmailsFromDB = keyBy(secondaryEmailsFromDB, "id");
|
||||
|
||||
const recordsToModifyQueue = modifiedRecords.map((updated) => {
|
||||
let emailVerified = keyedSecondaryEmailsFromDB[updated.id].emailVerified;
|
||||
if (secondaryEmail?.id === updated.id) {
|
||||
emailVerified = primaryEmailVerified;
|
||||
} else if (updated.email !== keyedSecondaryEmailsFromDB[updated.id].email) {
|
||||
emailVerified = null;
|
||||
}
|
||||
|
||||
return prisma.secondaryEmail.update({
|
||||
where: {
|
||||
id: updated.id,
|
||||
userId: updatedUser.id,
|
||||
},
|
||||
data: {
|
||||
email: updated.email,
|
||||
emailVerified,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await prisma.$transaction(recordsToModifyQueue);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...input,
|
||||
email: emailVerification && !secondaryEmail?.emailVerified ? user.email : input.email,
|
||||
avatarUrl: updatedUser.avatarUrl,
|
||||
hasEmailBeenChanged,
|
||||
sendEmailVerification: emailVerification && !secondaryEmail?.emailVerified,
|
||||
};
|
||||
};
|
||||
|
||||
const cleanMetadataAllowedUpdateKeys = (metadata: TUpdateProfileInputSchema["metadata"]) => {
|
||||
if (!metadata) {
|
||||
return {};
|
||||
}
|
||||
const cleanedMetadata = updateUserMetadataAllowedKeys.safeParse(metadata);
|
||||
if (!cleanedMetadata.success) {
|
||||
logger.error("Error cleaning metadata", cleanedMetadata.error);
|
||||
return {};
|
||||
}
|
||||
|
||||
return cleanedMetadata.data;
|
||||
};
|
||||
|
||||
const handleUserMetadata = ({ ctx, input }: UpdateProfileOptions) => {
|
||||
const { user } = ctx;
|
||||
const cleanMetadata = cleanMetadataAllowedUpdateKeys(input.metadata);
|
||||
const userMetadata = userMetadataSchema.parse(user.metadata);
|
||||
// Required so we don't override and delete saved values
|
||||
return { ...userMetadata, ...cleanMetadata };
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
import { bookerLayouts, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const updateUserMetadataAllowedKeys = z.object({
|
||||
sessionTimeout: z.number().optional(), // Minutes
|
||||
defaultBookerLayouts: bookerLayouts.optional(),
|
||||
});
|
||||
|
||||
export const ZUpdateProfileInputSchema = z.object({
|
||||
username: z.string().optional(),
|
||||
name: z.string().max(FULL_NAME_LENGTH_MAX_LIMIT).optional(),
|
||||
email: z.string().optional(),
|
||||
bio: z.string().optional(),
|
||||
avatarUrl: z.string().nullable().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
weekStart: z.string().optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
allowDynamicBooking: z.boolean().optional(),
|
||||
allowSEOIndexing: z.boolean().optional(),
|
||||
receiveMonthlyDigestEmail: z.boolean().optional(),
|
||||
brandColor: z.string().optional(),
|
||||
darkBrandColor: z.string().optional(),
|
||||
theme: z.string().optional().nullable(),
|
||||
appTheme: z.string().optional().nullable(),
|
||||
completedOnboarding: z.boolean().optional(),
|
||||
locale: z.string().optional(),
|
||||
timeFormat: z.number().optional(),
|
||||
disableImpersonation: z.boolean().optional(),
|
||||
metadata: userMetadata.optional(),
|
||||
travelSchedules: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
timeZone: z.string(),
|
||||
endDate: z.date().optional(),
|
||||
startDate: z.date(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
secondaryEmails: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
email: z.string(),
|
||||
isDeleted: z.boolean().default(false),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TUpdateProfileInputSchema = z.infer<typeof ZUpdateProfileInputSchema>;
|
||||
@@ -0,0 +1,60 @@
|
||||
import z from "zod";
|
||||
|
||||
import getApps from "@calcom/app-store/utils";
|
||||
import { getUsersCredentials } from "@calcom/lib/server/getUsersCredentials";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema";
|
||||
|
||||
type UpdateUserDefaultConferencingAppOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TUpdateUserDefaultConferencingAppInputSchema;
|
||||
};
|
||||
|
||||
export const updateUserDefaultConferencingAppHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: UpdateUserDefaultConferencingAppOptions) => {
|
||||
const currentMetadata = userMetadata.parse(ctx.user.metadata);
|
||||
const credentials = await getUsersCredentials(ctx.user);
|
||||
const foundApp = getApps(credentials, true).filter((app) => app.slug === input.appSlug)[0];
|
||||
const appLocation = foundApp?.appData?.location;
|
||||
|
||||
if (!foundApp || !appLocation) throw new TRPCError({ code: "BAD_REQUEST", message: "App not installed" });
|
||||
|
||||
if (appLocation.linkType === "static" && !input.appLink) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "App link is required" });
|
||||
}
|
||||
|
||||
if (appLocation.linkType === "static" && appLocation.urlRegExp) {
|
||||
const validLink = z
|
||||
.string()
|
||||
.regex(new RegExp(appLocation.urlRegExp), "Invalid App Link")
|
||||
.parse(input.appLink);
|
||||
if (!validLink) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid app link" });
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: {
|
||||
metadata: {
|
||||
...currentMetadata,
|
||||
defaultConferencingApp: {
|
||||
appSlug: input.appSlug,
|
||||
appLink: input.appLink,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return input;
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZUpdateUserDefaultConferencingAppInputSchema = z.object({
|
||||
appSlug: z.string().optional(),
|
||||
appLink: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TUpdateUserDefaultConferencingAppInputSchema = z.infer<
|
||||
typeof ZUpdateUserDefaultConferencingAppInputSchema
|
||||
>;
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { TFormSchema } from "@calcom/app-store/routing-forms/trpc/forms.schema";
|
||||
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
import { entries } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TWorkflowOrderInputSchema } from "./workflowOrder.schema";
|
||||
|
||||
type RoutingFormOrderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TWorkflowOrderInputSchema;
|
||||
};
|
||||
|
||||
export const workflowOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const { include: includedFields } = Prisma.validator<Prisma.WorkflowDefaultArgs>()({
|
||||
include: {
|
||||
activeOn: {
|
||||
select: {
|
||||
eventType: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
parentId: true,
|
||||
_count: {
|
||||
select: {
|
||||
children: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: true,
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
members: true,
|
||||
logoUrl: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const allWorkflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: includedFields,
|
||||
orderBy: [
|
||||
{
|
||||
position: "desc",
|
||||
},
|
||||
{
|
||||
id: "asc",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const allWorkflowIds = new Set(allWorkflows.map((workflow) => workflow.id));
|
||||
if (input.ids.some((id) => !allWorkflowIds.has(id))) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
input.ids.reverse().map((id, position) => {
|
||||
return prisma.workflow.update({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
data: {
|
||||
position,
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
type SupportedFilters = Omit<NonNullable<NonNullable<TFormSchema>["filters"]>, "upIds"> | undefined;
|
||||
|
||||
export function getPrismaWhereFromFilters(
|
||||
user: {
|
||||
id: number;
|
||||
},
|
||||
filters: SupportedFilters
|
||||
) {
|
||||
const where = {
|
||||
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
|
||||
};
|
||||
|
||||
const prismaQueries: Record<
|
||||
keyof NonNullable<typeof filters>,
|
||||
(...args: [number[]]) => Prisma.App_RoutingForms_FormWhereInput
|
||||
> & {
|
||||
all: () => Prisma.App_RoutingForms_FormWhereInput;
|
||||
} = {
|
||||
userIds: (userIds: number[]) => ({
|
||||
userId: {
|
||||
in: userIds,
|
||||
},
|
||||
teamId: null,
|
||||
}),
|
||||
teamIds: (teamIds: number[]) => ({
|
||||
team: {
|
||||
id: {
|
||||
in: teamIds ?? [],
|
||||
},
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
all: () => ({
|
||||
OR: [
|
||||
{
|
||||
userId: user.id,
|
||||
},
|
||||
{
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId: user.id,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
if (!filters || !hasFilter(filters)) {
|
||||
where.OR.push(prismaQueries.all());
|
||||
} else {
|
||||
for (const entry of entries(filters)) {
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const [filterName, filter] = entry;
|
||||
const getPrismaQuery = prismaQueries[filterName];
|
||||
// filter might be accidentally set undefined as well
|
||||
if (!getPrismaQuery || !filter) {
|
||||
continue;
|
||||
}
|
||||
where.OR.push(getPrismaQuery(filter));
|
||||
}
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZWorkflowOrderInputSchema = z.object({
|
||||
ids: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type TWorkflowOrderInputSchema = z.infer<typeof ZWorkflowOrderInputSchema>;
|
||||
70
calcom/packages/trpc/server/routers/publicViewer/_router.tsx
Normal file
70
calcom/packages/trpc/server/routers/publicViewer/_router.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import publicProcedure from "../../procedures/publicProcedure";
|
||||
import { importHandler, router } from "../../trpc";
|
||||
import { slotsRouter } from "../viewer/slots/_router";
|
||||
import { ZUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerificationRequired.schema";
|
||||
import { i18nInputSchema } from "./i18n.schema";
|
||||
import { ZNoShowInputSchema } from "./noShow.schema";
|
||||
import { event } from "./procedures/event";
|
||||
import { session } from "./procedures/session";
|
||||
import { ZSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
|
||||
import { ZStripeCheckoutSessionInputSchema } from "./stripeCheckoutSession.schema";
|
||||
import { ZSubmitRatingInputSchema } from "./submitRating.schema";
|
||||
|
||||
const NAMESPACE = "publicViewer";
|
||||
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
// things that unauthenticated users can query about themselves
|
||||
export const publicViewerRouter = router({
|
||||
session,
|
||||
i18n: publicProcedure.input(i18nInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("i18n"), () => import("./i18n.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
countryCode: publicProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("countryCode"), () => import("./countryCode.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
submitRating: publicProcedure.input(ZSubmitRatingInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("submitRating"), () => import("./submitRating.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
noShow: publicProcedure.input(ZNoShowInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(namespaced("noShow"), () => import("./noShow.handler"));
|
||||
return handler(opts);
|
||||
}),
|
||||
samlTenantProduct: publicProcedure.input(ZSamlTenantProductInputSchema).mutation(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("samlTenantProduct"),
|
||||
() => import("./samlTenantProduct.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
stripeCheckoutSession: publicProcedure.input(ZStripeCheckoutSessionInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("stripeCheckoutSession"),
|
||||
() => import("./stripeCheckoutSession.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
// REVIEW: This router is part of both the public and private viewer router?
|
||||
slots: slotsRouter,
|
||||
event,
|
||||
ssoConnections: publicProcedure.query(async () => {
|
||||
const handler = await importHandler(
|
||||
namespaced("ssoConnections"),
|
||||
() => import("./ssoConnections.handler")
|
||||
);
|
||||
return handler();
|
||||
}),
|
||||
|
||||
checkIfUserEmailVerificationRequired: publicProcedure
|
||||
.input(ZUserEmailVerificationRequiredSchema)
|
||||
.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("checkIfUserEmailVerificationRequired"),
|
||||
() => import("./checkIfUserEmailVerificationRequired.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
import type { TUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVerificationRequired.schema";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["checkIfUserEmailVerificationRequired"] });
|
||||
|
||||
export const userWithEmailHandler = async ({ input }: { input: TUserEmailVerificationRequiredSchema }) => {
|
||||
const { userSessionEmail, email } = input;
|
||||
const baseEmail = extractBaseEmail(email);
|
||||
|
||||
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
|
||||
? process.env.BLACKLISTED_GUEST_EMAILS.split(",")
|
||||
: [];
|
||||
|
||||
const blacklistedEmail = blacklistedGuestEmails.find(
|
||||
(guestEmail: string) => guestEmail.toLowerCase() === baseEmail.toLowerCase()
|
||||
);
|
||||
|
||||
if (!!blacklistedEmail && blacklistedEmail !== userSessionEmail) {
|
||||
log.warn(`blacklistedEmail: ${blacklistedEmail}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export default userWithEmailHandler;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZUserEmailVerificationRequiredSchema = z.object({
|
||||
userSessionEmail: z.string().optional(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type TUserEmailVerificationRequiredSchema = z.infer<typeof ZUserEmailVerificationRequiredSchema>;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { CreateInnerContextOptions } from "../../createContext";
|
||||
|
||||
type CountryCodeOptions = {
|
||||
ctx: CreateInnerContextOptions;
|
||||
};
|
||||
|
||||
export const countryCodeHandler = async ({ ctx }: CountryCodeOptions) => {
|
||||
const { req } = ctx;
|
||||
|
||||
const countryCode: string | string[] = req?.headers?.["x-vercel-ip-country"] ?? "";
|
||||
return { countryCode: Array.isArray(countryCode) ? countryCode[0] : countryCode };
|
||||
};
|
||||
|
||||
export default countryCodeHandler;
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { TEventInputSchema } from "./event.schema";
|
||||
|
||||
interface EventHandlerOptions {
|
||||
ctx: { prisma: PrismaClient };
|
||||
input: TEventInputSchema;
|
||||
}
|
||||
|
||||
export const eventHandler = async ({ ctx, input }: EventHandlerOptions) => {
|
||||
const event = await getPublicEvent(
|
||||
input.username,
|
||||
input.eventSlug,
|
||||
input.isTeamEvent,
|
||||
input.org,
|
||||
ctx.prisma,
|
||||
input.fromRedirectOfNonOrgLink
|
||||
);
|
||||
return event;
|
||||
};
|
||||
|
||||
export default eventHandler;
|
||||
@@ -0,0 +1,15 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZEventInputSchema = z.object({
|
||||
username: z.string(),
|
||||
eventSlug: z.string(),
|
||||
isTeamEvent: z.boolean().optional(),
|
||||
org: z.string().nullable(),
|
||||
/**
|
||||
* Informs that the event request has been sent from a page that was reached by a redirect from non-org link(i.e. app.cal.com/username redirected to acme.cal.com/username)
|
||||
* Based on this decision like whether to allow unpublished organization's event to be served or not can be made.
|
||||
*/
|
||||
fromRedirectOfNonOrgLink: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type TEventInputSchema = z.infer<typeof ZEventInputSchema>;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { I18nInputSchema } from "./i18n.schema";
|
||||
|
||||
type I18nOptions = {
|
||||
input: I18nInputSchema;
|
||||
};
|
||||
|
||||
export const i18nHandler = async ({ input }: I18nOptions) => {
|
||||
const { locale } = input;
|
||||
const { serverSideTranslations } = await import("next-i18next/serverSideTranslations");
|
||||
const i18n = await serverSideTranslations(locale, ["common", "vital"]);
|
||||
|
||||
return {
|
||||
i18n,
|
||||
locale,
|
||||
};
|
||||
};
|
||||
|
||||
export default i18nHandler;
|
||||
@@ -0,0 +1,15 @@
|
||||
import { lookup } from "bcp-47-match";
|
||||
import { z } from "zod";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { i18n } = require("@calcom/config/next-i18next.config");
|
||||
|
||||
export const i18nInputSchema = z.object({
|
||||
locale: z
|
||||
.string()
|
||||
.min(2)
|
||||
.transform((locale) => lookup(i18n.locales, locale) || locale),
|
||||
CalComVersion: z.string(),
|
||||
});
|
||||
|
||||
export type I18nInputSchema = z.infer<typeof i18nInputSchema>;
|
||||
@@ -0,0 +1,15 @@
|
||||
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
|
||||
|
||||
import type { TNoShowInputSchema } from "./noShow.schema";
|
||||
|
||||
type NoShowOptions = {
|
||||
input: TNoShowInputSchema;
|
||||
};
|
||||
|
||||
export const noShowHandler = async ({ input }: NoShowOptions) => {
|
||||
const { bookingUid, attendees, noShowHost } = input;
|
||||
|
||||
return handleMarkNoShow({ bookingUid, attendees, noShowHost });
|
||||
};
|
||||
|
||||
export default noShowHandler;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZNoShowInputSchema = z
|
||||
.object({
|
||||
bookingUid: z.string(),
|
||||
attendees: z
|
||||
.array(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
noShow: z.boolean(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
noShowHost: z.boolean().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
return (data.attendees && data.attendees.length > 0) || data.noShowHost !== undefined;
|
||||
},
|
||||
{
|
||||
message: "At least one of 'attendees' or 'noShowHost' must be provided",
|
||||
path: ["attendees", "noShowHost"],
|
||||
}
|
||||
);
|
||||
|
||||
export type TNoShowInputSchema = z.infer<typeof ZNoShowInputSchema>;
|
||||
@@ -0,0 +1,11 @@
|
||||
import publicProcedure from "../../../procedures/publicProcedure";
|
||||
import { importHandler } from "../../../trpc";
|
||||
import { ZEventInputSchema } from "../event.schema";
|
||||
|
||||
const NAMESPACE = "publicViewer";
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
export const event = publicProcedure.input(ZEventInputSchema).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("event"), () => import("../event.handler"));
|
||||
return handler(opts);
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import sessionMiddleware from "../../../middlewares/sessionMiddleware";
|
||||
import publicProcedure from "../../../procedures/publicProcedure";
|
||||
import { importHandler } from "../../../trpc";
|
||||
|
||||
const NAMESPACE = "publicViewer";
|
||||
|
||||
const namespaced = (s: string) => `${NAMESPACE}.${s}`;
|
||||
|
||||
export const session = publicProcedure.use(sessionMiddleware).query(async (opts) => {
|
||||
const handler = await importHandler(namespaced("session"), () => import("../session.handler"));
|
||||
return handler(opts);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ssoTenantProduct } from "@calcom/features/ee/sso/lib/sso";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
import type { TSamlTenantProductInputSchema } from "./samlTenantProduct.schema";
|
||||
|
||||
type SamlTenantProductOptions = {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
};
|
||||
input: TSamlTenantProductInputSchema;
|
||||
};
|
||||
|
||||
export const samlTenantProductHandler = ({ ctx, input }: SamlTenantProductOptions) => {
|
||||
const { prisma } = ctx;
|
||||
const { email } = input;
|
||||
|
||||
return ssoTenantProduct(prisma, email);
|
||||
};
|
||||
|
||||
export default samlTenantProductHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZSamlTenantProductInputSchema = z.object({
|
||||
email: z.string().email(),
|
||||
});
|
||||
|
||||
export type TSamlTenantProductInputSchema = z.infer<typeof ZSamlTenantProductInputSchema>;
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Session } from "next-auth";
|
||||
|
||||
type SessionOptions = {
|
||||
ctx: {
|
||||
session: Session | null;
|
||||
};
|
||||
};
|
||||
|
||||
export const sessionHandler = async ({ ctx }: SessionOptions) => {
|
||||
return ctx.session;
|
||||
};
|
||||
|
||||
export default sessionHandler;
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -0,0 +1,31 @@
|
||||
import jackson from "@calcom/features/ee/sso/lib/jackson";
|
||||
import { isSAMLLoginEnabled, samlProductID, samlTenantID } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const handler = async () => {
|
||||
try {
|
||||
if (HOSTED_CAL_FEATURES || !isSAMLLoginEnabled) {
|
||||
return {
|
||||
connectionExists: null,
|
||||
};
|
||||
}
|
||||
|
||||
const { connectionController } = await jackson();
|
||||
|
||||
const connections = await connectionController.getConnections({
|
||||
tenant: samlTenantID,
|
||||
product: samlProductID,
|
||||
});
|
||||
|
||||
return {
|
||||
connectionExists: connections.length > 0,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Error getting SSO connections", err);
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Fetching SSO connections failed." });
|
||||
}
|
||||
};
|
||||
|
||||
export default handler;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user