Fixes/app store keys in db (#2651)

* Adds available apps

* Adds App Model

* WIP

* Updates seeder script

* Seeder fixes

* lowercase categories

* Upgrades prisma

* WIP

* WIP

* Hopefully fixes circular deps

* Type fixes

* Fixes seeder

* Adds migration to connect Credentials to Apps

* Updates app store callbacks

* Updates google credentials

* Uses dirName from DB

* Type fixes

* Update reschedule.ts

* Seeder fixes

* Fixes categories listing

* Update index.ts

* Update schema.prisma

* Updates dependencies

* Renames giphy app

* Uses dynamic imports for app metadata

* Fixes credentials error

* Uses dynamic import for api handlers

* Dynamic import fixes

* Allows for simple folder names in app store

* Squashes app migrations

* seeder fixes

* Fixes dyamic imports

* Update apiHandlers.tsx
This commit is contained in:
Omar López 2022-05-02 14:39:35 -06:00
parent 11f6972ec9
commit 2e6bc5e5b4
68 changed files with 995 additions and 1628 deletions

View File

@ -117,6 +117,7 @@ EMAIL_SERVER_PASSWORD='<office365_password>'
# **********************************************************************************************************
# - APP STORE **********************************************************************************************
# ⚠️ ⚠️ ⚠️ THESE WILL BE MIGRATED TO THE DATABASE TO PREVENT AWS's 4KB ENV QUOTA ⚠️ ⚠️ ⚠️
# - DAILY.CO VIDEO
DAILY_API_KEY=
DAILY_SCALE_PLAN=''

@ -1 +1 @@
Subproject commit 943cd10de1f6661273d2ec18acdaa93118852714
Subproject commit cf71a8b47ec9d37da7e4facb61356a293cb0bd13

@ -1 +1 @@
Subproject commit 2449d90bcbbf4c0a379f4d766aee299caad488a2
Subproject commit 6124577bc21502c018378a299e50fc96bff14b99

View File

@ -11,15 +11,15 @@ import {
MoonIcon,
ViewGridIcon,
} from "@heroicons/react/solid";
import { UserPlan } from "@prisma/client";
import { SessionContextValue, signOut, useSession } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/router";
import React, { Fragment, ReactNode, useEffect } from "react";
import toast, { Toaster } from "react-hot-toast";
import { Toaster } from "react-hot-toast";
import { useIsEmbed } from "@calcom/embed-core";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { UserPlan } from "@calcom/prisma/client";
import Button from "@calcom/ui/Button";
import Dropdown, {
DropdownMenuContent,

View File

@ -1,6 +1,6 @@
import { Attendee } from "@prisma/client";
import { TFunction } from "next-i18next";
import { Attendee } from "@calcom/prisma/client";
import { Person } from "@calcom/types/Calendar";
export const attendeeToPersonConversionType = (attendees: Attendee[], t: TFunction): Person[] => {

View File

@ -6,7 +6,6 @@ import utc from "dayjs/plugin/utc";
import { createEvent, DateArray, Person } from "ics";
import { getCancelLink } from "@calcom/lib/CalEventParser";
import { Attendee } from "@calcom/prisma/client";
import { CalendarEvent } from "@calcom/types/Calendar";
import {

View File

@ -1,6 +1,4 @@
import { PrismaClient } from "@prisma/client";
import { Prisma } from "@calcom/prisma/client";
import { Prisma, PrismaClient } from "@prisma/client";
async function getBooking(prisma: PrismaClient, uid: string) {
const booking = await prisma.booking.findFirst({

View File

@ -1,6 +1,4 @@
import { useEffect } from "react";
import { UserPlan } from "@calcom/prisma/client";
import { UserPlan } from "@prisma/client";
/**
* TODO: It should be exposed at a single place.

View File

@ -87,7 +87,7 @@ const nextConfig = {
];
},
async redirects() {
return [
const redirects = [
{
source: "/settings",
destination: "/settings/profile",
@ -104,6 +104,28 @@ const nextConfig = {
permanent: false,
},
];
if (process.env.NEXT_PUBLIC_WEBAPP_URL === "https://app.cal.com") {
redirects.push(
{
source: "/apps/dailyvideo",
destination: "/apps/daily-video",
permanent: true,
},
{
source: "/apps/huddle01_video",
destination: "/apps/huddle01",
permanent: true,
},
{
source: "/apps/jitsi_video",
destination: "/apps/jitsi",
permanent: true,
}
);
}
return redirects;
},
};

View File

@ -1,7 +1,5 @@
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import appStore from "@calcom/app-store";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
@ -19,13 +17,10 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const appName = _appName.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
try {
// TODO: Find a way to dynamically import these modules
// const app = (await import(`@calcom/${appName}`)).default;
const app = appStore[appName as keyof typeof appStore];
if (!(app && "api" in app && apiEndpoint in app.api))
throw new HttpError({ statusCode: 404, message: `API handler not found` });
const handler = app.api[apiEndpoint as keyof typeof app.api] as NextApiHandler;
/* Absolute path didn't work */
const handlerMap = (await import("@calcom/app-store/apiHandlers")).default;
const handlers = await handlerMap[appName as keyof typeof handlerMap];
const handler = handlers[apiEndpoint as keyof typeof handlers] as NextApiHandler;
if (typeof handler !== "function")
throw new HttpError({ statusCode: 404, message: `API handler not found` });

View File

@ -1,13 +1,14 @@
import fs from "fs";
import matter from "gray-matter";
import { GetStaticPaths, GetStaticPathsResult, GetStaticPropsContext } from "next";
import { GetStaticPaths, GetStaticPropsContext } from "next";
import { MDXRemote } from "next-mdx-remote";
import { serialize } from "next-mdx-remote/serialize";
import Image from "next/image";
import Link from "next/link";
import path from "path";
import { getAppRegistry } from "@calcom/app-store/_appRegistry";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma";
import useMediaQuery from "@lib/hooks/useMediaQuery";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -68,11 +69,8 @@ function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
}
export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
const appStore = getAppRegistry();
const paths = appStore.reduce((paths, app) => {
paths.push({ params: { slug: app.slug } });
return paths;
}, [] as GetStaticPathsResult<{ slug: string }>["paths"]);
const appStore = await prisma.app.findMany({ select: { slug: true } });
const paths = appStore.map(({ slug }) => ({ params: { slug } }));
return {
paths,
@ -81,23 +79,19 @@ export const getStaticPaths: GetStaticPaths<{ slug: string }> = async () => {
};
export const getStaticProps = async (ctx: GetStaticPropsContext) => {
const appStore = getAppRegistry();
if (typeof ctx.params?.slug !== "string") return { notFound: true };
if (typeof ctx.params?.slug !== "string") {
return {
notFound: true,
};
}
const app = await prisma.app.findUnique({
where: { slug: ctx.params.slug },
});
const singleApp = appStore.find((app) => app.slug === ctx.params?.slug);
if (!app) return { notFound: true };
if (!singleApp) {
return {
notFound: true,
};
}
const singleApp = await getAppWithMetadata(app);
const appDirname = singleApp.type.replace("_", "");
if (!singleApp) return { notFound: true };
const appDirname = app.dirName;
const README_PATH = path.join(process.cwd(), "..", "..", `packages/app-store/${appDirname}/README.mdx`);
const postFilePath = path.join(README_PATH);
let source = "";

View File

@ -50,7 +50,7 @@ export default function Apps({ appStore }: InferGetStaticPropsType<typeof getSta
}
export const getStaticPaths = async () => {
const appStore = getAppRegistry();
const appStore = await getAppRegistry();
const paths = appStore.reduce((categories, app) => {
if (!categories.includes(app.category)) {
categories.push(app.category);
@ -67,7 +67,7 @@ export const getStaticPaths = async () => {
export const getStaticProps = async () => {
return {
props: {
appStore: getAppRegistry(),
appStore: await getAppRegistry(),
},
};
};

View File

@ -30,7 +30,7 @@ export default function Apps({ categories }: InferGetStaticPropsType<typeof getS
}
export const getStaticProps = async () => {
const appStore = getAppRegistry();
const appStore = await getAppRegistry();
const categories = appStore.reduce((c, app) => {
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
return c;

View File

@ -24,7 +24,7 @@ export default function Apps({ appStore, categories }: InferGetStaticPropsType<t
}
export const getStaticProps = async () => {
const appStore = getAppRegistry();
const appStore = await getAppRegistry();
const categories = appStore.reduce((c, app) => {
c[app.category] = c[app.category] ? c[app.category] + 1 : 1;
return c;

View File

@ -136,26 +136,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
})[0];
async function getBooking() {
return prisma.booking.findFirst({
where: {
uid: asStringOrThrow(context.query.rescheduleUid),
},
select: {
description: true,
attendees: {
select: {
email: true,
name: true,
},
},
},
});
}
type Booking = Prisma.PromiseReturnType<typeof getBooking>;
let booking: Booking | null = null;
const profile = {
name: user.name || user.username,
image: user.avatar,
@ -173,7 +153,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
locationLabels: getLocationLabels(t),
profile,
eventType: eventTypeObject,
booking,
booking: null,
trpcState: ssr.dehydrate(),
isDynamicGroupBooking: false,
hasHashedBookingLink: true,

View File

@ -29,7 +29,7 @@ import { FormattedNumber, IntlProvider } from "react-intl";
import { JSONObject } from "superjson/dist/types";
import { z } from "zod";
import { SelectGifInput } from "@calcom/app-store/giphyother/components";
import { SelectGifInput } from "@calcom/app-store/giphy/components";
import getApps, { getLocationOptions, hasIntegration } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
@ -2104,6 +2104,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
type: true,
key: true,
userId: true,
appId: true,
},
});

View File

@ -708,6 +708,7 @@ export async function getServerSideProps(context: NextPageContext) {
type: true,
key: true,
userId: true,
appId: true,
},
});

View File

@ -1,8 +1,7 @@
import { UserPlan } from "@prisma/client";
import { GetServerSidePropsContext } from "next";
import { JSONObject } from "superjson/dist/types";
import { UserPlan } from "@calcom/prisma/client";
import { asStringOrNull } from "@lib/asStringOrNull";
import { getWorkingHours } from "@lib/availability";
import getBooking, { GetBookingType } from "@lib/getBooking";

View File

@ -55,6 +55,7 @@ async function getUserFromSession({
type: true,
key: true,
userId: true,
appId: true,
},
orderBy: {
id: "asc",

View File

@ -1,10 +1,9 @@
import { expect, it } from "@jest/globals";
import { Availability } from "@prisma/client";
import dayjs from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import MockDate from "mockdate";
import { Availability } from "@calcom/prisma/client";
import { getAvailabilityFromSchedule } from "@lib/availability";
dayjs.extend(customParseFormat);

View File

@ -1,15 +1,33 @@
import prisma from "@calcom/prisma";
import { App } from "@calcom/types/App";
import appStoreMetadata from "./metadata";
export async function getAppWithMetadata(app: { dirName: string }) {
let appMetadata: App | null = null;
try {
appMetadata = (await import(`./${app.dirName}/_metadata`)).default as App;
} catch (error) {
if (error instanceof Error) {
console.error(`No metadata found for: "${app.dirName}". Message:`, error.message);
}
return null;
}
if (!appMetadata) return null;
// Let's not leak api keys to the front end
const { key, ...metadata } = appMetadata;
return metadata;
}
/** Mainly to use in listings for the frontend, use in getStaticProps or getServerSideProps */
export function getAppRegistry() {
return Object.values(appStoreMetadata).reduce((apps, app) => {
export async function getAppRegistry() {
const dbApps = await prisma.app.findMany({ select: { dirName: true, slug: true, categories: true } });
const apps = [] as Omit<App, "key">[];
for await (const dbapp of dbApps) {
const app = await getAppWithMetadata(dbapp);
if (!app) continue;
// Skip if app isn't installed
if (!app.installed) return apps;
// Let's not leak api keys to the front end
const { key, ...metadata } = app;
apps.push(metadata);
return apps;
}, [] as Omit<App, "key">[]);
/* This is now handled from the DB */
// if (!app.installed) return apps;
apps.push(app);
}
return apps;
}

View File

@ -0,0 +1,10 @@
import { Prisma } from "@prisma/client";
import prisma from "@calcom/prisma";
async function getAppKeysFromSlug(slug: string) {
const app = await prisma.app.findUnique({ where: { slug } });
return app?.keys as Prisma.JsonObject;
}
export default getAppKeysFromSlug;

View File

@ -5,7 +5,7 @@ import { WEBAPP_URL } from "@calcom/lib/constants";
import { App } from "@calcom/types/App";
function useAddAppMutation(type: App["type"], options?: Parameters<typeof useMutation>[2]) {
const appName = type.replaceAll("_", "");
const appName = type.replace(/_/g, "");
const mutation = useMutation(async () => {
const state: IntegrationOAuthCallbackState = {
returnTo: WEBAPP_URL + "/apps/installed" + location.search,

View File

@ -0,0 +1,19 @@
export const apiHandlers = {
// examplevideo: import("./_example/api"),
applecalendar: import("./applecalendar/api"),
caldavcalendar: import("./caldavcalendar/api"),
googlecalendar: import("./googlecalendar/api"),
hubspotothercalendar: import("./hubspotothercalendar/api"),
office365calendar: import("./office365calendar/api"),
slackmessaging: import("./slackmessaging/api"),
stripepayment: import("./stripepayment/api"),
tandemvideo: import("./tandemvideo/api"),
zoomvideo: import("@calcom/zoomvideo/api"),
office365video: import("@calcom/office365video/api"),
wipemycalother: import("./wipemycalother/api"),
jitsivideo: import("./jitsivideo/api"),
huddle01video: import("./huddle01video/api"),
giphy: import("./giphy/api"),
};
export default apiHandlers;

View File

@ -24,6 +24,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: "apple_calendar",
key: symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY!),
userId: user.id,
appId: "apple-calendar",
};
try {

View File

@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
process.env.CALENDSO_ENCRYPTION_KEY!
),
userId: user.id,
appId: "caldav-calendar",
};
try {

View File

@ -23,7 +23,7 @@ export const InstallAppButtonMap = {
wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")),
jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")),
huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")),
giphyother: dynamic(() => import("./giphyother/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
};
export const InstallAppButton = (
@ -33,8 +33,14 @@ export const InstallAppButton = (
) => {
const { status } = useSession();
const { t } = useLocale();
const appName = props.type.replaceAll("_", "") as keyof typeof InstallAppButtonMap;
const InstallAppButtonComponent = InstallAppButtonMap[appName];
let appName = props.type.replace(/_/g, "");
let InstallAppButtonComponent = InstallAppButtonMap[appName as keyof typeof InstallAppButtonMap];
/** So we can either call it by simple name (ex. `slack`, `giphy`) instead of
* `slackmessaging`, `giphyother` while maintaining retro-compatibility. */
if (!InstallAppButtonComponent) {
[appName] = props.type.split("_");
InstallAppButtonComponent = InstallAppButtonMap[appName as keyof typeof InstallAppButtonMap];
}
if (!InstallAppButtonComponent) return null;
if (status === "unauthenticated")
return (

View File

@ -57,6 +57,7 @@ export const FAKE_DAILY_CREDENTIAL: Credential = {
type: "daily_video",
key: { apikey: process.env.DAILY_API_KEY },
userId: +new Date().getTime(),
appId: "daily-video",
};
const DailyVideoApiAdapter = (credential: Credential): VideoApiAdapter => {

View File

@ -8,8 +8,8 @@ export const metadata = {
installed: !!process.env.GIPHY_API_KEY,
category: "other",
// If using static next public folder, can then be referenced from the base URL (/).
imageSrc: "/api/app-store/giphyother/icon.svg",
logo: "/api/app-store/giphyother/icon.svg",
imageSrc: "/api/app-store/giphy/icon.svg",
logo: "/api/app-store/giphy/icon.svg",
publisher: "Cal.com",
rating: 0,
reviews: 0,

View File

@ -2,7 +2,8 @@ import useAddAppMutation from "../../_utils/useAddAppMutation";
import { InstallAppButtonProps } from "../../types";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation("giphy_other");
// @ts-ignore TODO: deprecate App types in favor of DB slugs
const mutation = useAddAppMutation("giphy");
return (
<>

View File

@ -30,7 +30,7 @@ export const SearchDialog = (props: ISearchDialog) => {
}
setIsLoading(true);
setErrorMessage("");
const res = await fetch("/api/integrations/giphyother/search", {
const res = await fetch("/api/integrations/giphy/search", {
method: "POST",
headers: {
"Content-Type": "application/json",

View File

@ -1,6 +1,15 @@
import { HttpError } from "@calcom/lib/http-error";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
let api_key = "";
export const searchGiphy = async (locale: string, keyword: string, offset: number = 0) => {
const appKeys = await getAppKeysFromSlug("giphy");
if (typeof appKeys.api_key === "string") api_key = appKeys.api_key;
if (!api_key) throw new HttpError({ statusCode: 400, message: "Missing Giphy api_key" });
const queryParams = new URLSearchParams({
api_key: String(process.env.GIPHY_API_KEY),
api_key,
q: keyword,
limit: "1",
offset: String(offset),

View File

Before

Width:  |  Height:  |  Size: 734 B

After

Width:  |  Height:  |  Size: 734 B

View File

@ -4,17 +4,24 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { encodeOAuthState } from "../../_utils/encodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const credentials = process.env.GOOGLE_API_CREDENTIALS!;
const scopes = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
let client_id = "";
let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "GET") {
// Get token from Google Calendar API
const { client_secret, client_id } = JSON.parse(credentials).web;
const appKeys = await getAppKeysFromSlug("google-calendar");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Google client_id missing." });
if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." });
const redirect_uri = WEBAPP_URL + "/api/integrations/googlecalendar/callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);

View File

@ -6,8 +6,10 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const credentials = process.env.GOOGLE_API_CREDENTIALS;
let client_id = "";
let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
@ -15,11 +17,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
res.status(400).json({ message: "`code` must be a string" });
return;
}
if (!credentials) {
res.status(400).json({ message: "There are no Google Credentials installed." });
return;
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const { client_secret, client_id } = JSON.parse(credentials).web;
const appKeys = await getAppKeysFromSlug("google-calendar");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Google client_id missing." });
if (!client_secret) return res.status(400).json({ message: "Google client_secret missing." });
const redirect_uri = WEBAPP_URL + "/api/integrations/googlecalendar/callback";
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
@ -36,7 +43,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
type: "google_calendar",
key,
userId: req.session?.user.id,
userId: req.session.user.id,
appId: "google-calendar",
},
});
const state = decodeOAuthState(req);

View File

@ -4,6 +4,7 @@ import { Auth, calendar_v3, google } from "googleapis";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import CalendarService from "@calcom/lib/CalendarService";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type {
@ -14,26 +15,38 @@ import type {
NewCalendarEventType,
} from "@calcom/types/Calendar";
const GOOGLE_API_CREDENTIALS = process.env.GOOGLE_API_CREDENTIALS || "";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
export default class GoogleCalendarService implements Calendar {
private url = "";
private integrationName = "";
private auth: { getToken: () => Promise<MyGoogleAuth> };
private auth: Promise<{ getToken: () => Promise<MyGoogleAuth> }>;
private log: typeof logger;
private client_id = "";
private client_secret = "";
private redirect_uri = "";
constructor(credential: Credential) {
this.integrationName = "google_calendar";
this.auth = this.googleAuth(credential);
this.auth = this.googleAuth(credential).then((m) => m);
this.log = logger.getChildLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}
private googleAuth = (credential: Credential) => {
const { client_secret, client_id, redirect_uris } = JSON.parse(GOOGLE_API_CREDENTIALS).web;
private googleAuth = async (credential: Credential) => {
const appKeys = await getAppKeysFromSlug("google-calendar");
if (typeof appKeys.client_id === "string") this.client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") this.client_secret = appKeys.client_secret;
if (typeof appKeys.redirect_uris === "object" && Array.isArray(appKeys.redirect_uris)) {
this.redirect_uri = appKeys.redirect_uris[0] as string;
}
if (!this.client_id) throw new HttpError({ statusCode: 400, message: "Google client_id missing." });
if (!this.client_secret)
throw new HttpError({ statusCode: 400, message: "Google client_secret missing." });
if (!this.redirect_uri) throw new HttpError({ statusCode: 400, message: "Google redirect_uri missing." });
const myGoogleAuth = new MyGoogleAuth(client_id, client_secret, redirect_uris[0]);
const myGoogleAuth = new MyGoogleAuth(this.client_id, this.client_secret, this.redirect_uri);
const googleCredentials = credential.key as Auth.Credentials;
myGoogleAuth.setCredentials(googleCredentials);
@ -43,23 +56,20 @@ export default class GoogleCalendarService implements Calendar {
const refreshAccessToken = () =>
myGoogleAuth
.refreshToken(googleCredentials.refresh_token)
.then((res: GetTokenResponse) => {
.then(async (res: GetTokenResponse) => {
const token = res.res?.data;
googleCredentials.access_token = token.access_token;
googleCredentials.expiry_date = token.expiry_date;
return prisma.credential
.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
})
.then(() => {
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
});
await prisma.credential.update({
where: {
id: credential.id,
},
data: {
key: googleCredentials as Prisma.InputJsonValue,
},
});
myGoogleAuth.setCredentials(googleCredentials);
return myGoogleAuth;
})
.catch((err) => {
this.log.error("Error refreshing google token", err);
@ -73,164 +83,164 @@ export default class GoogleCalendarService implements Calendar {
};
async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
description: getRichDescription(calEventRaw),
start: {
dateTime: calEventRaw.startTime,
timeZone: calEventRaw.organizer.timeZone,
},
end: {
dateTime: calEventRaw.endTime,
timeZone: calEventRaw.organizer.timeZone,
},
attendees: calEventRaw.attendees.map((attendee) => ({
...attendee,
responseStatus: "accepted",
})),
reminders: {
useDefault: true,
},
};
return new Promise(async (resolve, reject) => {
const auth = await this.auth;
const myGoogleAuth = await auth.getToken();
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
description: getRichDescription(calEventRaw),
start: {
dateTime: calEventRaw.startTime,
timeZone: calEventRaw.organizer.timeZone,
},
end: {
dateTime: calEventRaw.endTime,
timeZone: calEventRaw.organizer.timeZone,
},
attendees: calEventRaw.attendees.map((attendee) => ({
...attendee,
responseStatus: "accepted",
})),
reminders: {
useDefault: true,
},
};
if (calEventRaw.location) {
payload["location"] = getLocation(calEventRaw);
}
if (calEventRaw.location) {
payload["location"] = getLocation(calEventRaw);
}
if (calEventRaw.conferenceData && calEventRaw.location === "integrations:google:meet") {
payload["conferenceData"] = calEventRaw.conferenceData;
}
const calendar = google.calendar({
version: "v3",
});
calendar.events.insert(
{
auth: myGoogleAuth,
if (calEventRaw.conferenceData && calEventRaw.location === "integrations:google:meet") {
payload["conferenceData"] = calEventRaw.conferenceData;
}
const calendar = google.calendar({
version: "v3",
});
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: calEventRaw.destinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId
: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: calEventRaw.destinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId
: "primary",
requestBody: payload,
conferenceDataVersion: 1,
},
function (err, event) {
if (err || !event?.data) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
auth: myGoogleAuth,
eventId: event.data.id || "",
requestBody: {
description: getRichDescription({
...calEventRaw,
additionInformation: { hangoutLink: event.data.hangoutLink || "" },
}),
},
});
calendar.events.patch({
// Update the same event but this time we know the hangout link
calendarId: calEventRaw.destinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId
: "primary",
auth: myGoogleAuth,
eventId: event.data.id || "",
requestBody: {
description: getRichDescription({
...calEventRaw,
additionInformation: { hangoutLink: event.data.hangoutLink || "" },
}),
},
});
return resolve({
uid: "",
...event.data,
id: event.data.id || "",
additionalInfo: {
hangoutLink: event.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
});
}
);
})
);
return resolve({
uid: "",
...event.data,
id: event.data.id || "",
additionalInfo: {
hangoutLink: event.data.hangoutLink || "",
},
type: "google_calendar",
password: "",
url: "",
});
}
);
});
}
async updateEvent(uid: string, event: CalendarEvent): Promise<any> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: true,
},
};
return new Promise(async (resolve, reject) => {
const auth = await this.auth;
const myGoogleAuth = await auth.getToken();
const payload: calendar_v3.Schema$Event = {
summary: event.title,
description: getRichDescription(event),
start: {
dateTime: event.startTime,
timeZone: event.organizer.timeZone,
},
end: {
dateTime: event.endTime,
timeZone: event.organizer.timeZone,
},
attendees: event.attendees,
reminders: {
useDefault: true,
},
};
if (event.location) {
payload["location"] = getLocation(event);
}
if (event.location) {
payload["location"] = getLocation(event);
}
const calendar = google.calendar({
version: "v3",
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
});
calendar.events.update(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
requestBody: payload,
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
return reject(err);
}
);
})
);
return resolve(event?.data);
}
);
});
}
async deleteEvent(uid: string, event: CalendarEvent): Promise<void> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
return new Promise(async (resolve, reject) => {
const auth = await this.auth;
const myGoogleAuth = await auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
});
calendar.events.delete(
{
auth: myGoogleAuth,
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
return resolve(event?.data);
calendarId: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
: "primary",
eventId: uid,
sendNotifications: true,
sendUpdates: "all",
},
function (err, event) {
if (err) {
console.error("There was an error contacting google calendar service: ", err);
return reject(err);
}
);
})
);
return resolve(event?.data);
}
);
});
}
async getAvailability(
@ -238,96 +248,96 @@ export default class GoogleCalendarService implements Calendar {
dateTo: string,
selectedCalendars: IntegrationCalendar[]
): Promise<EventBusyDate[]> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
return new Promise(async (resolve, reject) => {
const auth = await this.auth;
const myGoogleAuth = await auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
const selectedCalendarIds = selectedCalendars
.filter((e) => e.integration === this.integrationName)
.map((e) => e.externalId);
if (selectedCalendarIds.length === 0 && selectedCalendars.length > 0) {
// Only calendars of other integrations selected
resolve([]);
return;
}
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
(selectedCalendarIds.length === 0
? calendar.calendarList
.list()
.then((cals) => cals.data.items?.map((cal) => cal.id).filter(Boolean) || [])
: Promise.resolve(selectedCalendarIds)
)
.then((calsIds) => {
calendar.freebusy.query(
{
requestBody: {
timeMin: dateFrom,
timeMax: dateTo,
items: calsIds.map((id) => ({ id: id })),
},
(err, apires) => {
if (err) {
reject(err);
}
let result: Prisma.PromiseReturnType<CalendarService["getAvailability"]> = [];
if (apires?.data.calendars) {
result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as typeof result);
}
resolve(result);
},
(err, apires) => {
if (err) {
reject(err);
}
);
})
.catch((err) => {
this.log.error("There was an error contacting google calendar service: ", err);
let result: Prisma.PromiseReturnType<CalendarService["getAvailability"]> = [];
reject(err);
});
})
);
if (apires?.data.calendars) {
result = Object.values(apires.data.calendars).reduce((c, i) => {
i.busy?.forEach((busyTime) => {
c.push({
start: busyTime.start || "",
end: busyTime.end || "",
});
});
return c;
}, [] as typeof result);
}
resolve(result);
}
);
})
.catch((err) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
});
}
async listCalendars(): Promise<IntegrationCalendar[]> {
return new Promise((resolve, reject) =>
this.auth.getToken().then((myGoogleAuth) => {
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
return new Promise(async (resolve, reject) => {
const auth = await this.auth;
const myGoogleAuth = await auth.getToken();
const calendar = google.calendar({
version: "v3",
auth: myGoogleAuth,
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: this.integrationName,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err: Error) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
calendar.calendarList
.list()
.then((cals) => {
resolve(
cals.data.items?.map((cal) => {
const calendar: IntegrationCalendar = {
externalId: cal.id ?? "No id",
integration: this.integrationName,
name: cal.summary ?? "No name",
primary: cal.primary ?? false,
};
return calendar;
}) || []
);
})
.catch((err: Error) => {
this.log.error("There was an error contacting google calendar service: ", err);
reject(err);
});
})
);
});
}
}

View File

@ -7,9 +7,10 @@ import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const client_id = process.env.HUBSPOT_CLIENT_ID;
const client_secret = process.env.HUBSPOT_CLIENT_SECRET;
let client_id = "";
let client_secret = "";
const hubspotClient = new hubspot.Client();
export type HubspotToken = TokenResponseIF & {
@ -24,15 +25,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
if (!client_id) {
res.status(400).json({ message: "HubSpot client id missing." });
return;
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
if (!client_secret) {
res.status(400).json({ message: "HubSpot client secret missing." });
return;
}
const appKeys = await getAppKeysFromSlug("hubspot");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "HubSpot client id missing." });
if (!client_secret) return res.status(400).json({ message: "HubSpot client secret missing." });
const hubspotToken: HubspotToken = await hubspotClient.oauth.tokensApi.createToken(
"authorization_code",
@ -48,7 +49,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
type: "hubspot_other_calendar",
key: hubspotToken as any,
userId: req.session?.user.id,
userId: req.session.user.id,
appId: "hubspot",
},
});

View File

@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: appType,
key: {},
userId: req.session.user.id,
appId: "huddle01",
},
});
if (!installation) {

View File

@ -2,7 +2,7 @@
import * as applecalendar from "./applecalendar";
import * as caldavcalendar from "./caldavcalendar";
import * as dailyvideo from "./dailyvideo";
import * as giphyother from "./giphyother";
import * as giphy from "./giphy";
import * as googlecalendar from "./googlecalendar";
import * as googlevideo from "./googlevideo";
import * as hubspotothercalendar from "./hubspotothercalendar";
@ -33,7 +33,7 @@ const appStore = {
tandemvideo,
zoomvideo,
wipemycalother,
giphyother,
giphy,
};
export default appStore;

View File

@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: appType,
key: {},
userId: req.session.user.id,
appId: "jitsi",
},
});
if (!installation) {

View File

@ -1,7 +1,7 @@
import { metadata as applecalendar } from "./applecalendar/_metadata";
import { metadata as caldavcalendar } from "./caldavcalendar/_metadata";
import { metadata as dailyvideo } from "./dailyvideo/_metadata";
import { metadata as giphy } from "./giphyother/_metadata";
import { metadata as giphy } from "./giphy/_metadata";
import { metadata as googlecalendar } from "./googlecalendar/_metadata";
import { metadata as googlevideo } from "./googlevideo/_metadata";
import { metadata as hubspotothercalendar } from "./hubspotothercalendar/_metadata";

View File

@ -1,13 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { BASE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
let client_id = "";
let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
@ -16,18 +20,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
const appKeys = await getAppKeysFromSlug("office365-calendar");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Office 365 client_id missing." });
if (!client_secret) return res.status(400).json({ message: "Office 365 client_secret missing." });
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => key + "=" + encodeURIComponent(payload[key]))
.join("&");
const body = toUrlEncoded({
client_id: process.env.MS_GRAPH_CLIENT_ID!,
client_id,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: BASE_URL + "/api/integrations/office365calendar/callback",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET!,
redirect_uri: WEBAPP_URL + "/api/integrations/office365calendar/callback",
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
@ -59,6 +69,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: "office365_calendar",
key: responseBody,
userId: req.session?.user.id,
appId: "office365-calendar",
},
});

View File

@ -1,13 +1,17 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { BASE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
const scopes = ["OnlineMeetings.ReadWrite"];
let client_id = "";
let client_secret = "";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const { code } = req.query;
console.log("🚀 ~ file: callback.ts ~ line 14 ~ handler ~ code", req.query);
@ -17,18 +21,24 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
const appKeys = await getAppKeysFromSlug("office365-calendar");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (typeof appKeys.client_secret === "string") client_secret = appKeys.client_secret;
if (!client_id) return res.status(400).json({ message: "Office 365 client_id missing." });
if (!client_secret) return res.status(400).json({ message: "Office 365 client_secret missing." });
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => key + "=" + encodeURIComponent(payload[key]))
.join("&");
const body = toUrlEncoded({
client_id: process.env.MS_GRAPH_CLIENT_ID!,
client_id,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: BASE_URL + "/api/integrations/office365video/callback",
client_secret: process.env.MS_GRAPH_CLIENT_SECRET!,
redirect_uri: WEBAPP_URL + "/api/integrations/office365video/callback",
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
@ -60,6 +70,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: "office365_video",
key: responseBody,
userId: req.session?.user.id,
appId: "msteams",
},
});

View File

@ -3,7 +3,9 @@ import { stringify } from "querystring";
import prisma from "@calcom/prisma";
const client_id = process.env.SLACK_CLIENT_ID;
import getAppKeysFromSlug from "../../_utils/getAppKeysFromSlug";
let client_id = "";
const scopes = ["commands", "users:read", "users:read.email", "chat:write", "chat:write.public"];
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -12,6 +14,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (req.method === "GET") {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const appKeys = await getAppKeysFromSlug("slack");
if (typeof appKeys.client_id === "string") client_id = appKeys.client_id;
if (!client_id) return res.status(400).json({ message: "Slack client_id missing" });
// Get user
await prisma.user.findFirst({
rejectOnNotFound: true,

View File

@ -14,6 +14,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return;
}
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code: code.toString(),
@ -29,7 +33,8 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
data: {
type: "stripe_payment",
key: data as unknown as Prisma.InputJsonObject,
userId: req.session?.user.id,
userId: req.session.user.id,
appId: "stripe",
},
});

View File

@ -14,7 +14,7 @@ const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => {
}, {} as Record<string, App>);
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true, key: true, userId: true },
select: { id: true, type: true, key: true, userId: true, appId: true },
});
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
@ -66,6 +66,7 @@ function getApps(userCredentials: CredentialData[]) {
type: appMeta.type,
key: appMeta.key!,
userId: +new Date().getTime(),
appId: appMeta.slug,
});
}

View File

@ -27,6 +27,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
type: appType,
key: {},
userId: req.session.user.id,
appId: "wipe-my-cal",
},
});
if (!installation) {

View File

@ -1,77 +0,0 @@
import { Credential } from "@prisma/client";
import _ from "lodash";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult } from "@calcom/types/EventManager";
import { getCalendar } from "../../_utils/getCalendar";
const log = logger.getChildLogger({ prefix: ["CalendarManager"] });
/** TODO: Remove once all references are updated to app-store */
export { getCalendar };
export const createEvent = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
const uid: string = getUid(calEvent);
const calendar = getCalendar(credential);
let success = true;
// Check if the disabledNotes flag is set to true
if (calEvent.hideCalendarNotes) {
calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
const creationResult = calendar
? await calendar.createEvent(calEvent).catch((e) => {
log.error("createEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
createdEvent: creationResult,
originalEvent: calEvent,
};
};
export const updateEvent = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRefUid: string | null
): Promise<EventResult> => {
const uid = getUid(calEvent);
const calendar = getCalendar(credential);
let success = true;
const updatedResult =
calendar && bookingRefUid
? await calendar.updateEvent(bookingRefUid, calEvent).catch((e) => {
log.error("updateEvent failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
return {
type: credential.type,
success,
uid,
updatedEvent: updatedResult,
originalEvent: calEvent,
};
};
export const deleteEvent = (credential: Credential, uid: string, event: CalendarEvent): Promise<unknown> => {
const calendar = getCalendar(credential);
if (calendar) {
return calendar.deleteEvent(uid, event);
}
return Promise.resolve({});
};

View File

@ -1,386 +0,0 @@
import { Credential, DestinationCalendar } from "@prisma/client";
import async from "async";
import merge from "lodash/merge";
import { v5 as uuidv5 } from "uuid";
import prisma from "@calcom/prisma";
import type { AdditionInformation, CalendarEvent } from "@calcom/types/Calendar";
import type {
CreateUpdateResult,
EventResult,
PartialBooking,
PartialReference,
} from "@calcom/types/EventManager";
import type { VideoCallData } from "@calcom/types/VideoApiAdapter";
import { LocationType } from "../../locations";
import getApps from "../../utils";
import { createEvent, updateEvent } from "./calendarManager";
import { createMeeting, updateMeeting } from "./videoClient";
export type Event = AdditionInformation & VideoCallData;
export const isZoom = (location: string): boolean => {
return location === "integrations:zoom";
};
export const isDaily = (location: string): boolean => {
return location === "integrations:daily";
};
export const isHuddle01 = (location: string): boolean => {
return location === "integrations:huddle01";
};
export const isTandem = (location: string): boolean => {
return location === "integrations:tandem";
};
export const isTeams = (location: string): boolean => {
return location === "integrations:office365_video";
};
export const isJitsi = (location: string): boolean => {
return location === "integrations:jitsi";
};
export const isDedicatedIntegration = (location: string): boolean => {
return (
isZoom(location) ||
isDaily(location) ||
isHuddle01(location) ||
isTandem(location) ||
isJitsi(location) ||
isTeams(location)
);
};
export const getLocationRequestFromIntegration = (location: string) => {
if (
/** TODO: Handle this dynamically */
location === LocationType.GoogleMeet.valueOf() ||
location === LocationType.Zoom.valueOf() ||
location === LocationType.Daily.valueOf() ||
location === LocationType.Jitsi.valueOf() ||
location === LocationType.Huddle01.valueOf() ||
location === LocationType.Tandem.valueOf() ||
location === LocationType.Teams.valueOf()
) {
const requestId = uuidv5(location, uuidv5.URL);
return {
conferenceData: {
createRequest: {
requestId: requestId,
},
},
location,
};
}
return null;
};
export const processLocation = (event: CalendarEvent): CalendarEvent => {
// If location is set to an integration location
// Build proper transforms for evt object
// Extend evt object with those transformations
if (event.location?.includes("integration")) {
const maybeLocationRequestObject = getLocationRequestFromIntegration(event.location);
event = merge(event, maybeLocationRequestObject);
}
return event;
};
type EventManagerUser = {
credentials: Credential[];
destinationCalendar: DestinationCalendar | null;
};
export default class EventManager {
calendarCredentials: Credential[];
videoCredentials: Credential[];
/**
* Takes an array of credentials and initializes a new instance of the EventManager.
*
* @param user
*/
constructor(user: EventManagerUser) {
const appCredentials = getApps(user.credentials).flatMap((app) => app.credentials);
this.calendarCredentials = appCredentials.filter((cred) => cred.type.endsWith("_calendar"));
this.videoCredentials = appCredentials.filter((cred) => cred.type.endsWith("_video"));
}
/**
* Takes a CalendarEvent and creates all necessary integration entries for it.
* When a video integration is chosen as the event's location, a video integration
* event will be scheduled for it as well.
*
* @param event
*/
public async create(event: CalendarEvent): Promise<CreateUpdateResult> {
const evt = processLocation(event);
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult> = [];
// If and only if event type is a dedicated meeting, create a dedicated video meeting.
if (isDedicated) {
const result = await this.createVideoEvent(evt);
if (result.createdEvent) {
evt.videoCallData = result.createdEvent;
}
results.push(result);
}
// Create the calendar event with the proper video call data
results.push(...(await this.createAllCalendarEvents(evt)));
const referencesToCreate: Array<PartialReference> = results.map((result: EventResult) => {
return {
type: result.type,
uid: result.createdEvent?.id.toString() ?? "",
meetingId: result.createdEvent?.id.toString(),
meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.url,
};
});
return {
results,
referencesToCreate,
};
}
/**
* Takes a calendarEvent and a rescheduleUid and updates the event that has the
* given uid using the data delivered in the given CalendarEvent.
*
* @param event
*/
public async update(
event: CalendarEvent,
rescheduleUid: string,
newBookingId?: number
): Promise<CreateUpdateResult> {
const evt = processLocation(event);
if (!rescheduleUid) {
throw new Error("You called eventManager.update without an `rescheduleUid`. This should never happen.");
}
// Get details of existing booking.
const booking = await prisma.booking.findFirst({
where: {
uid: rescheduleUid,
},
select: {
id: true,
references: {
// NOTE: id field removed from select as we don't require for deletingMany
// but was giving error on recreate for reschedule, probably because promise.all() didn't finished
select: {
type: true,
uid: true,
meetingId: true,
meetingPassword: true,
meetingUrl: true,
},
},
destinationCalendar: true,
payment: true,
},
});
if (!booking) {
throw new Error("booking not found");
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
const results: Array<EventResult> = [];
// If and only if event type is a dedicated meeting, update the dedicated video meeting.
if (isDedicated) {
const result = await this.updateVideoEvent(evt, booking);
const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent];
if (updatedEvent) {
evt.videoCallData = updatedEvent;
evt.location = updatedEvent.url;
}
results.push(result);
}
// Update all calendar events.
results.push(...(await this.updateAllCalendarEvents(evt, booking)));
const bookingPayment = booking?.payment;
// Updating all payment to new
if (bookingPayment && newBookingId) {
const paymentIds = bookingPayment.map((payment) => payment.id);
await prisma.payment.updateMany({
where: {
id: {
in: paymentIds,
},
},
data: {
bookingId: newBookingId,
},
});
}
// Now we can delete the old booking and its references.
const bookingReferenceDeletes = prisma.bookingReference.deleteMany({
where: {
bookingId: booking.id,
},
});
const attendeeDeletes = prisma.attendee.deleteMany({
where: {
bookingId: booking.id,
},
});
const bookingDeletes = prisma.booking.delete({
where: {
id: booking.id,
},
});
// Wait for all deletions to be applied.
await Promise.all([bookingReferenceDeletes, attendeeDeletes, bookingDeletes]);
return {
results,
referencesToCreate: [...booking.references],
};
}
/**
* Creates event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* When the optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @param noMail
* @private
*/
private async createAllCalendarEvents(event: CalendarEvent): Promise<Array<EventResult>> {
/** Can I use destinationCalendar here? */
/* How can I link a DC to a cred? */
if (event.destinationCalendar) {
const destinationCalendarCredentials = this.calendarCredentials.filter(
(c) => c.type === event.destinationCalendar?.integration
);
return Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)));
}
/**
* Not ideal but, if we don't find a destination calendar,
* fallback to the first connected calendar
*/
const [credential] = this.calendarCredentials;
if (!credential) {
return [];
}
return [await createEvent(credential, event)];
}
/**
* Checks which video integration is needed for the event's location and returns
* credentials for that - if existing.
* @param event
* @private
*/
private getVideoCredential(event: CalendarEvent): Credential | undefined {
if (!event.location) {
return undefined;
}
const integrationName = event.location.replace("integrations:", "");
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));
}
/**
* Creates a video event entry for the selected integration location.
*
* When optional uid is set, it will be used instead of the auto generated uid.
*
* @param event
* @private
*/
private createVideoEvent(event: CalendarEvent): Promise<EventResult> {
const credential = this.getVideoCredential(event);
if (credential) {
return createMeeting(credential, event);
} else {
return Promise.reject(
`No suitable credentials given for the requested integration name:${event.location}`
);
}
}
/**
* Updates the event entries for all calendar integrations given in the credentials.
* When noMail is true, no mails will be sent. This is used when the event is
* a video meeting because then the mail containing the video credentials will be
* more important than the mails created for these bare calendar events.
*
* @param event
* @param booking
* @private
*/
private updateAllCalendarEvents(
event: CalendarEvent,
booking: PartialBooking
): Promise<Array<EventResult>> {
return async.mapLimit(this.calendarCredentials, 5, async (credential: Credential) => {
const bookingRefUid = booking
? booking.references.filter((ref) => ref.type === credential.type)[0]?.uid
: null;
return updateEvent(credential, event, bookingRefUid);
});
}
/**
* Updates a single video event.
*
* @param event
* @param booking
* @private
*/
private updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
const credential = this.getVideoCredential(event);
if (credential) {
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;
return updateMeeting(credential, event, bookingRef);
} else {
return Promise.reject(
`No suitable credentials given for the requested integration name:${event.location}`
);
}
}
/**
* Update event to set a cancelled event placeholder on users calendar
* remove if virtual calendar is already done and user availability its read from there
* and not only in their calendars
* @param event
* @param booking
* @public
*/
public async updateAndSetCancelledPlaceholder(event: CalendarEvent, booking: PartialBooking) {
await this.updateAllCalendarEvents(event, booking);
}
}

View File

@ -1,9 +1,11 @@
import { BookingStatus, User, Booking, BookingReference } from "@prisma/client";
import { Booking, BookingReference, BookingStatus, User } from "@prisma/client";
import dayjs from "dayjs";
import type { TFunction } from "next-i18next";
import EventManager from "@calcom/core/EventManager";
import { CalendarEventBuilder } from "@calcom/core/builders/CalendarEvent/builder";
import { CalendarEventDirector } from "@calcom/core/builders/CalendarEvent/director";
import { deleteMeeting } from "@calcom/core/videoClient";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma from "@calcom/prisma";
@ -11,8 +13,6 @@ import { Person } from "@calcom/types/Calendar";
import { getCalendar } from "../../_utils/getCalendar";
import { sendRequestRescheduleEmail } from "./emailManager";
import EventManager from "./eventManager";
import { deleteMeeting } from "./videoClient";
type PersonAttendeeCommonFields = Pick<User, "id" | "email" | "name" | "locale" | "timeZone" | "username">;

View File

@ -1,116 +0,0 @@
// @NOTE: This was copy from core since core uses app/store and creates circular dependency
// @TODO: Improve import export on appstore/core
import { Credential } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { getUid } from "@calcom/lib/CalEventParser";
import logger from "@calcom/lib/logger";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { EventResult, PartialReference } from "@calcom/types/EventManager";
import type { VideoApiAdapter, VideoApiAdapterFactory } from "@calcom/types/VideoApiAdapter";
import appStore from "../../index";
const log = logger.getChildLogger({ prefix: ["[lib] videoClient"] });
const translator = short();
// factory
const getVideoAdapters = (withCredentials: Credential[]): VideoApiAdapter[] =>
withCredentials.reduce<VideoApiAdapter[]>((acc, cred) => {
const appName = cred.type.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const app = appStore[appName as keyof typeof appStore];
if (app && "lib" in app && "VideoApiAdapter" in app.lib) {
const makeVideoApiAdapter = app.lib.VideoApiAdapter as VideoApiAdapterFactory;
const videoAdapter = makeVideoApiAdapter(cred);
acc.push(videoAdapter);
return acc;
}
return acc;
}, []);
const getBusyVideoTimes = (withCredentials: Credential[]) =>
Promise.all(getVideoAdapters(withCredentials).map((c) => c.getAvailability())).then((results) =>
results.reduce((acc, availability) => acc.concat(availability), [])
);
const createMeeting = async (credential: Credential, calEvent: CalendarEvent): Promise<EventResult> => {
const uid: string = getUid(calEvent);
if (!credential) {
throw new Error(
"Credentials must be set! Video platforms are optional, so this method shouldn't even be called when no video credentials are set."
);
}
const videoAdapters = getVideoAdapters([credential]);
const [firstVideoAdapter] = videoAdapters;
const createdMeeting = await firstVideoAdapter.createMeeting(calEvent).catch((e) => {
log.error("createMeeting failed", e, calEvent);
});
if (!createdMeeting) {
return {
type: credential.type,
success: false,
uid,
originalEvent: calEvent,
};
}
return {
type: credential.type,
success: true,
uid,
createdEvent: createdMeeting,
originalEvent: calEvent,
};
};
const updateMeeting = async (
credential: Credential,
calEvent: CalendarEvent,
bookingRef: PartialReference | null
): Promise<EventResult> => {
const uid = translator.fromUUID(uuidv5(JSON.stringify(calEvent), uuidv5.URL));
let success = true;
const [firstVideoAdapter] = getVideoAdapters([credential]);
const updatedMeeting =
credential && bookingRef
? await firstVideoAdapter.updateMeeting(bookingRef, calEvent).catch((e) => {
log.error("updateMeeting failed", e, calEvent);
success = false;
return undefined;
})
: undefined;
if (!updatedMeeting) {
return {
type: credential.type,
success,
uid,
originalEvent: calEvent,
};
}
return {
type: credential.type,
success,
uid,
updatedEvent: updatedMeeting,
originalEvent: calEvent,
};
};
const deleteMeeting = (credential: Credential, uid: string): Promise<unknown> => {
if (credential) {
return getVideoAdapters([credential])[0].deleteMeeting(uid);
}
return Promise.resolve({});
};
export { getBusyVideoTimes, createMeeting, updateMeeting, deleteMeeting };

View File

@ -304,6 +304,7 @@ export default class EventManager {
return undefined;
}
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
const integrationName = event.location.replace("integrations:", "");
return this.videoCredentials.find((credential: Credential) => credential.type.includes(integrationName));

View File

@ -1,10 +1,10 @@
import type { Availability } from "@prisma/client";
import dayjs, { ConfigType } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { nameOfDay } from "@calcom/lib/weekday";
import type { Availability } from "@calcom/prisma/client";
import type { Schedule, TimeRange, WorkingHours } from "@calcom/types/schedule";
dayjs.extend(utc);

View File

@ -0,0 +1,43 @@
-- CreateEnum
CREATE TYPE "AppCategories" AS ENUM ('calendar', 'messaging', 'other', 'payment', 'video', 'web3');
-- AlterTable
ALTER TABLE "Credential" ADD COLUMN "appId" TEXT;
-- CreateTable
CREATE TABLE "App" (
"slug" TEXT NOT NULL,
"dirName" TEXT NOT NULL,
"keys" JSONB,
"categories" "AppCategories"[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "App_pkey" PRIMARY KEY ("slug")
);
-- CreateIndex
CREATE UNIQUE INDEX "App_slug_key" ON "App"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "App_dirName_key" ON "App"("dirName");
-- AddForeignKey
ALTER TABLE "Credential" ADD CONSTRAINT "Credential_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App"("slug") ON DELETE CASCADE ON UPDATE CASCADE;
-- Connects each saved Credential to their respective App
UPDATE "Credential" SET "appId" = 'apple-calendar' WHERE "type" = 'apple_calendar';
UPDATE "Credential" SET "appId" = 'caldav-calendar' WHERE "type" = 'caldav_calendar';
UPDATE "Credential" SET "appId" = 'google-calendar' WHERE "type" = 'google_calendar';
UPDATE "Credential" SET "appId" = 'google-meet' WHERE "type" = 'google_video';
UPDATE "Credential" SET "appId" = 'office365-calendar' WHERE "type" = 'office365_calendar';
UPDATE "Credential" SET "appId" = 'msteams' WHERE "type" = 'office365_video';
UPDATE "Credential" SET "appId" = 'dailyvideo' WHERE "type" = 'daily_video';
UPDATE "Credential" SET "appId" = 'tandem' WHERE "type" = 'tandem_video';
UPDATE "Credential" SET "appId" = 'zoom' WHERE "type" = 'zoom_video';
UPDATE "Credential" SET "appId" = 'jitsi' WHERE "type" = 'jitsi_video';
UPDATE "Credential" SET "appId" = 'hubspot' WHERE "type" = 'hubspot_other_calendar';
UPDATE "Credential" SET "appId" = 'wipe-my-cal' WHERE "type" = 'wipemycal_other';
UPDATE "Credential" SET "appId" = 'huddle01' WHERE "type" = 'huddle01_video';
UPDATE "Credential" SET "appId" = 'slack' WHERE "type" = 'slack_messaging';
UPDATE "Credential" SET "appId" = 'stripe' WHERE "type" = 'stripe_payment';

View File

@ -13,21 +13,22 @@
"db-setup": "run-s db-up db-deploy db-seed",
"db-studio": "yarn prisma studio",
"db-up": "docker-compose up -d",
"deploy": "yarn prisma migrate deploy",
"deploy": "yarn prisma migrate deploy && yarn seed-app-store",
"dx": "yarn db-setup",
"generate-schemas": "prisma generate && prisma format",
"postinstall": "yarn generate-schemas"
"postinstall": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts"
},
"devDependencies": {
"npm-run-all": "^4.1.5",
"prisma": "3.10.0",
"prisma": "^3.13.0",
"ts-node": "^10.6.0",
"zod": "^3.14.4",
"zod-prisma": "^0.5.4"
},
"dependencies": {
"@calcom/lib": "*",
"@prisma/client": "^3.12.0"
"@prisma/client": "^3.13.0"
},
"main": "index.ts",
"types": "index.d.ts",
@ -37,6 +38,6 @@
"zod-utils.ts"
],
"prisma": {
"seed": "ts-node ./seed.ts"
"seed": "ts-node --transpile-only ./seed.ts"
}
}

View File

@ -77,11 +77,13 @@ model EventType {
}
model Credential {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
type String
key Json
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
appId String?
}
enum UserPlan {
@ -440,3 +442,26 @@ model Session {
expires DateTime
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum AppCategories {
calendar
messaging
other
payment
video
web3
}
model App {
// The slug for the app store public page inside `/apps/[slug]`
slug String @id @unique
// The directory name for `/packages/app-store/[dirName]`
dirName String @unique
// Needed API Keys
keys Json?
// One or multiple categories to which this app belongs
categories AppCategories[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
credentials Credential[]
}

View File

@ -0,0 +1,113 @@
import { Prisma } from "@prisma/client";
import prisma from ".";
require("dotenv").config({ path: "../../.env" });
async function createApp(
slug: Prisma.AppCreateInput["slug"],
/** The directory name for `/packages/app-store/[dirName]` */
dirName: Prisma.AppCreateInput["dirName"],
categories: Prisma.AppCreateInput["categories"],
keys?: Prisma.AppCreateInput["keys"]
) {
await prisma.app.upsert({
where: { slug },
create: { slug, dirName, categories, keys },
update: { dirName, categories, keys },
});
console.log(`📲 Upserted app: '${slug}'`);
}
async function main() {
// Calendar apps
await createApp("apple-calendar", "applecalendar", ["calendar"]);
await createApp("caldav-calendar", "caldavcalendar", ["calendar"]);
try {
const { client_secret, client_id, redirect_uris } = JSON.parse(process.env.GOOGLE_API_CREDENTIALS).web;
await createApp("google-calendar", "googlecalendar", ["calendar"], {
client_id,
client_secret,
redirect_uris,
});
await createApp("google-meet", "googlevideo", ["video"], { client_id, client_secret, redirect_uris });
} catch (e) {
if (e instanceof Error) console.error("Error adding google credentials to DB:", e.message);
}
if (process.env.MS_GRAPH_CLIENT_ID && process.env.MS_GRAPH_CLIENT_SECRET) {
await createApp("office365-calendar", "office365calendar", ["calendar"], {
client_id: process.env.MS_GRAPH_CLIENT_ID,
client_secret: process.env.MS_GRAPH_CLIENT_SECRET,
});
await createApp("msteams", "office365video", ["video"]);
}
// Video apps
if (process.env.DAILY_API_KEY) {
await createApp("dailyvideo", "dailyvideo", ["video"], {
api_key: process.env.DAILY_API_KEY,
scale_plan: process.env.DAILY_SCALE_PLAN,
});
}
if (process.env.TANDEM_CLIENT_ID && process.env.TANDEM_CLIENT_SECRET) {
await createApp("tandem", "tandemvideo", ["video"], {
client_id: process.env.TANDEM_CLIENT_ID as string,
client_secret: process.env.TANDEM_CLIENT_SECRET as string,
base_url: (process.env.TANDEM_BASE_URL as string) || "https://tandem.chat",
});
}
if (process.env.ZOOM_CLIENT_ID && process.env.ZOOM_CLIENT_SECRET) {
await createApp("zoom", "zoomvideo", ["video"], {
client_id: process.env.ZOOM_CLIENT_ID,
client_secret: process.env.ZOOM_CLIENT_SECRET,
});
}
await createApp("jitsi", "jitsivideo", ["video"]);
// Other apps
if (process.env.HUBSPOT_CLIENT_ID && process.env.HUBSPOT_CLIENT_SECRET) {
await createApp("hubspot", "hubspotothercalendar", ["other"], {
client_id: process.env.HUBSPOT_CLIENT_ID,
client_secret: process.env.HUBSPOT_CLIENT_SECRET,
});
}
await createApp("wipe-my-cal", "wipemycalother", ["other"]);
if (process.env.GIPHY_API_KEY) {
await createApp("giphy", "giphy", ["other"], {
api_key: process.env.GIPHY_API_KEY,
});
}
// Web3 apps
await createApp("huddle01", "huddle01video", ["web3", "video"]);
// Messaging apps
if (process.env.SLACK_CLIENT_ID && process.env.SLACK_CLIENT_SECRET && process.env.SLACK_SIGNING_SECRET) {
await createApp("slack", "slackmessaging", ["messaging"], {
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
signing_secret: process.env.SLACK_SIGNING_SECRET,
});
}
// Payment apps
if (
process.env.STRIPE_CLIENT_ID &&
process.env.STRIPE_PRIVATE_KEY &&
process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY &&
process.env.STRIPE_WEBHOOK_SECRET
) {
await createApp("stripe", "stripepayment", ["payment"], {
client_id: process.env.STRIPE_CLIENT_ID,
client_secret: process.env.STRIPE_PRIVATE_KEY,
payment_fee_fixed: 10,
payment_fee_percentage: 0.005,
public_key: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
});
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -1,13 +1,14 @@
import { MembershipRole, Prisma, PrismaClient, UserPlan } from "@prisma/client";
import { MembershipRole, Prisma, UserPlan } from "@prisma/client";
import dayjs from "dayjs";
import { uuid } from "short-uuid";
import { hashPassword } from "@calcom/lib/auth";
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
require("dotenv").config({ path: "../../.env" });
import prisma from ".";
import "./seed-app-store";
const prisma = new PrismaClient();
require("dotenv").config({ path: "../../.env" });
async function createUserAndEventType(opts: {
user: {

0
test
View File

985
yarn.lock

File diff suppressed because it is too large Load Diff