Compare commits

...

13 Commits

Author SHA1 Message Date
Hariom Balhara 21ece8d9f6 Changes to make type ready to remove 2022-06-03 12:18:09 +05:30
Hariom Balhara c6ca411b54 Merge remote-tracking branch 'origin/main' into feature/app-store-cli 2022-06-03 09:27:55 +05:30
Hariom Balhara d313ade54c Remove Demo apps 2022-06-03 09:10:10 +05:30
Hariom Balhara 95d7c17e4b Use slug everywhere, add edit command and other improvements 2022-06-02 18:00:33 +05:30
Hariom Balhara 721e1c0d33 Fix existing apps 2022-06-02 13:08:15 +05:30
Hariom Balhara 8ecaa95dc9 Improvements 2022-06-01 17:29:03 +05:30
Hariom Balhara d563343669 Use slug everywhere instead of app type 2022-06-01 15:15:07 +05:30
Hariom Balhara c563415795 Add app-store:watch 2022-06-01 13:07:38 +05:30
Hariom Balhara 18c8057be7 Stability and add delete command 2022-05-31 16:31:37 +05:30
Hariom Balhara bcfd22614b Merge remote-tracking branch 'origin/main' into feature/app-store-cli 2022-05-31 15:56:50 +05:30
Hariom Balhara 3a3c3a918b Add cli 2022-05-27 21:20:33 +05:30
Hariom Balhara 40ad51381e Autogenerate apps config 2022-05-27 17:02:28 +05:30
Hariom Balhara 86e7ba09e4 Add sample cli app 2022-05-27 14:06:26 +05:30
85 changed files with 3061 additions and 320 deletions

7
.vscode/tasks.json vendored
View File

@ -73,6 +73,13 @@
"command": "maildev -s 587",
"isBackground": false,
"problemMatcher": []
},
{
"label": "AppStoreWatch",
"type": "shell",
"command": "yarn app-store:watch",
"isBackground": false,
"problemMatcher": []
}
]
}

View File

@ -402,7 +402,7 @@ Next make sure you have your app running `yarn dx`. Then in the slack chat type
4. Fill in any information you want in the "App info" tab
5. Go to tab "Auth"
6. Now copy the Client ID and Client Secret to your .env file into the `HUBSPOT_CLIENT_ID` and `HUBSPOT_CLIENT_SECRET` fields.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspot othercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
7. Set the Redirect URL for OAuth `<Cal.com URL>/api/integrations/hubspotothercalendar/callback` replacing Cal.com URL with the URI at which your application runs.
8. In the "Scopes" section at the bottom of the page, make sure you select "Read" and "Write" for scope called `crm.objects.contacts`
9. Click the "Save" button at the bottom footer.
10. You're good to go. Now you can see any booking in Cal.com created as a meeting in HubSpot for your contacts.

View File

@ -4,7 +4,7 @@ import { OptionProps } from "react-select";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { Button } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
@ -14,11 +14,11 @@ interface AdditionalCalendarSelectorProps {
isLoading?: boolean;
}
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; type: App["type"] }>) => {
const ImageOption = (optionProps: OptionProps<{ [key: string]: string; appId: string }>) => {
const { data } = optionProps;
return (
<InstallAppButton
type={data.type}
slug={data.appId}
render={(installProps) => {
return (
<Button {...installProps} className="w-full" color="minimal">
@ -46,7 +46,7 @@ const AdditionalCalendarSelector = ({ isLoading }: AdditionalCalendarSelectorPro
label: item.name,
slug: item.slug,
image: item.imageSrc,
type: item.type,
appId: item.appId,
}));
return (
<Select

View File

@ -14,7 +14,7 @@ import React, { useEffect, useState } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { App as AppType } from "@calcom/types/App";
import { AppMeta as AppType } from "@calcom/types/App";
import { Button, SkeletonButton } from "@calcom/ui";
import Shell from "@components/Shell";
@ -23,6 +23,7 @@ import Badge from "@components/ui/Badge";
export default function App({
name,
type,
slug,
logo,
body,
categories,
@ -38,6 +39,7 @@ export default function App({
privacy,
}: {
name: string;
slug: string;
type: AppType["type"];
isGlobal?: AppType["isGlobal"];
logo: string;
@ -64,9 +66,9 @@ export default function App({
const [installedApp, setInstalledApp] = useState(0);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
async function getInstalledApp(appCredentialType: string) {
async function getInstalledApp(slug: string) {
const queryParam = new URLSearchParams();
queryParam.set("app-credential-type", appCredentialType);
queryParam.set("app-slug", slug);
try {
const result = await fetch(`/api/app-store/installed?${queryParam.toString()}`, {
method: "GET",
@ -87,8 +89,9 @@ export default function App({
}
}
}
getInstalledApp(type);
}, [type]);
getInstalledApp(slug);
}, [slug]);
return (
<>
<Shell large isPublic>
@ -123,7 +126,7 @@ export default function App({
: t("globally_install")}
</Button>
<InstallAppButton
type={type}
slug={slug}
render={(buttonProps) => (
<Button StartIcon={PlusIcon} data-testid="install-app-button" {...buttonProps}>
{t("add_another")}
@ -133,7 +136,7 @@ export default function App({
</div>
) : (
<InstallAppButton
type={type}
slug={slug}
render={(buttonProps) => (
<Button data-testid="install-app-button" {...buttonProps}>
{t("install_app")}

View File

@ -1,9 +1,9 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import AppCard from "./AppCard";
export default function AllApps({ apps }: { apps: App[] }) {
export default function AllApps({ apps }: { apps: AppMeta[] }) {
const { t } = useLocale();
return (

View File

@ -1,10 +1,10 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import AppCard from "./AppCard";
import Slider from "./Slider";
const TrendingAppsSlider = <T extends App>({ items }: { items: T[] }) => {
const TrendingAppsSlider = <T extends AppMeta>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (

View File

@ -131,7 +131,7 @@ function ConnectedCalendarsList(props: Props) {
key={cal.externalId}
externalId={cal.externalId}
title={cal.name || "Nameless calendar"}
type={item.integration.type}
type={item.integration.appId}
defaultSelected={cal.isSelected}
/>
))}

View File

@ -54,7 +54,11 @@ async function handlePaymentSuccess(event: Stripe.Event) {
user: {
select: {
id: true,
credentials: true,
credentials: {
include: {
app: true,
},
},
timeZone: true,
email: true,
name: true,

View File

@ -7,18 +7,32 @@ import { getSession } from "@lib/auth";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
req.session = await getSession({ req });
if (req.method === "GET" && req.session && req.session.user.id && req.query) {
const { "app-credential-type": appCredentialType } = req.query;
if (!appCredentialType && Array.isArray(appCredentialType)) {
const { "app-slug": appSlug } = req.query;
if (!appSlug || typeof appSlug !== "string") {
return res.status(400);
}
const userId = req.session.user.id;
let where;
if (appSlug === "giphy") {
where = {
userId: userId,
type: "giphy_other",
};
} else if (appSlug === "slack") {
where = {
userId: userId,
type: "slack_app",
};
} else {
where = {
userId: userId,
appId: appSlug,
};
}
try {
const installedApp = await prisma.credential.findMany({
where: {
type: appCredentialType as string,
userId: userId,
},
where,
});
if (installedApp && !!installedApp.length) {

View File

@ -63,6 +63,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
select: {
id: true,
credentials: {
include: {
app: true,
},
orderBy: { id: "desc" as Prisma.SortOrder },
},
timeZone: true,

View File

@ -113,7 +113,11 @@ const userSelect = Prisma.validator<Prisma.UserArgs>()({
name: true,
username: true,
timeZone: true,
credentials: true,
credentials: {
include: {
app: true,
},
},
bufferTime: true,
destinationCalendar: true,
locale: true,

View File

@ -1,5 +1,7 @@
import { NextApiHandler, NextApiRequest, NextApiResponse } from "next";
import { deriveAppKeyFromSlugOrType } from "@calcom/lib/deriveAppKeyFromSlugOrType";
import { getSession } from "@lib/auth";
import { HttpError } from "@lib/core/http/error";
@ -13,15 +15,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
return res.status(404).json({ message: `API route not found` });
}
const [_appName, apiEndpoint] = args;
const appName = _appName.split("_").join(""); // Transform `zoom_video` to `zoomvideo`;
const [appName, apiEndpoint] = args;
try {
/* 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;
const handlerMap = (await import("@calcom/app-store/apps.generated")).apiHandlers;
const handlerKey = deriveAppKeyFromSlugOrType(appName, handlerMap);
const handlers = await handlerMap[handlerKey];
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

@ -7,7 +7,7 @@ import Image from "next/image";
import Link from "next/link";
import path from "path";
import { getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import { getAppRegistry, getAppWithMetadata } from "@calcom/app-store/_appRegistry";
import prisma from "@calcom/prisma";
import useMediaQuery from "@lib/hooks/useMediaQuery";
@ -49,6 +49,7 @@ const components = {
function SingleAppPage({ data, source }: inferSSRProps<typeof getStaticProps>) {
return (
<App
slug={data.slug}
name={data.name}
isGlobal={data.isGlobal}
type={data.type}
@ -87,8 +88,14 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
if (!app) return { notFound: true };
const singleApp = await getAppWithMetadata(app);
let singleApp = await getAppWithMetadata(app);
const appStoreFromDb = await getAppRegistry();
appStoreFromDb.forEach((appFromDb) => {
singleApp = {
...singleApp,
...appFromDb,
};
});
if (!singleApp) return { notFound: true };
const appDirname = app.dirName;
@ -97,7 +104,7 @@ export const getStaticProps = async (ctx: GetStaticPropsContext) => {
let source = "";
try {
/* If the app doesn't have a README we fallback to the packagfe description */
/* If the app doesn't have a README we fallback to the package description */
source = fs.readFileSync(postFilePath).toString();
} catch (error) {
console.log(`No README.mdx provided for: ${appDirname}`);

View File

@ -6,7 +6,7 @@ import { JSONObject } from "superjson/dist/types";
import { InstallAppButton } from "@calcom/app-store/components";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { Alert } from "@calcom/ui/Alert";
import Button from "@calcom/ui/Button";
import EmptyScreen from "@calcom/ui/EmptyScreen";
@ -28,13 +28,13 @@ import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingT
function ConnectOrDisconnectIntegrationButton(props: {
credentialIds: number[];
type: App["type"];
slug: AppMeta["slug"];
isGlobal?: boolean;
installed?: boolean;
}) {
const { t } = useLocale();
const [credentialId] = props.credentialIds;
const type = props.type;
const slug = props.slug;
const utils = trpc.useContext();
const handleOpenChange = () => {
utils.invalidateQueries(["viewer.integrations"]);
@ -83,7 +83,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
}
return (
<InstallAppButton
type={props.type}
slug={props.slug}
render={(buttonProps) => (
<Button color="secondary" {...buttonProps} data-testid="integration-connection-button">
{t("connect")}
@ -95,7 +95,7 @@ function ConnectOrDisconnectIntegrationButton(props: {
}
interface IntegrationsContainerProps {
variant: App["variant"];
variant: AppMeta["variant"];
className?: string;
}
@ -127,7 +127,7 @@ const IntegrationsContainer = ({ variant, className = "" }: IntegrationsContaine
actions={
<ConnectOrDisconnectIntegrationButton
credentialIds={item.credentialIds}
type={item.type}
slug={item.appId}
isGlobal={item.isGlobal}
installed
/>

View File

@ -708,7 +708,7 @@ export async function getServerSideProps(context: NextPageContext) {
});
const integrations = getApps(credentials)
.filter((item) => item.type.endsWith("_calendar"))
.filter((item) => item.category.includes("calendar"))
.map((item) => omit(item, "key"));
// get user's credentials + their connected integrations

View File

@ -58,6 +58,9 @@ async function getUserFromSession({
userId: true,
appId: true,
},
include: {
app: true,
},
orderBy: {
id: "asc",
},

View File

@ -619,10 +619,25 @@ const loggedInViewerRouter = createProtectedRouter()
const { variant, onlyInstalled } = input;
const { credentials } = user;
// TODO: This fn doesn't seem to be used. Verify and remove it.
function countActive(items: { credentialIds: unknown[] }[]) {
return items.reduce((acc, item) => acc + item.credentialIds.length, 0);
}
let apps = getApps(credentials).map(
({ credentials: _, credential: _1 /* don't leak to frontend */, ...app }) => ({
...app,
credentialIds: credentials.filter((c) => c.type === app.type).map((c) => c.id),
credentialIds: credentials
.filter((c) => {
const slug = app.slug;
if (slug === "giphy") {
return c.type === "giphy_other";
} else if (slug === "slack") {
return c.type === "slack_app";
}
return c.appId === app.slug;
})
.map((c) => c.id),
})
);
if (variant) {

View File

@ -24,7 +24,9 @@
"docs-dev": "yarn predev && turbo run dev --scope=\"@calcom/docs\"",
"docs-build": "turbo run build --scope=\"@calcom/docs\" --include-dependencies",
"docs-start": "turbo run start --scope=\"@calcom/docs\"",
"dx": "yarn predev && (git submodule update || true) && turbo run dx",
"app-store:watch": "node packages/app-store/app-store.js --watch",
"dx:web": "yarn predev && (git submodule update || true) && turbo run dx",
"dx": "run-p 'dx:web' 'app-store:watch'",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"heroku-postbuild": "turbo run @calcom/web#build",
"lint": "turbo run lint",
@ -42,13 +44,15 @@
"embed-tests": "turbo run embed-tests",
"test-e2e": "turbo run test-e2e --concurrency=1",
"type-check": "turbo run type-check",
"app-store": "yarn workspace @calcom/app-store-cli cli",
"embed-tests-prepare": "yarn workspace @calcom/prisma db-reset && yarn build"
},
"devDependencies": {
"dotenv-checker": "^1.1.5",
"husky": "^8.0.1",
"lint-staged": "^12.4.1",
"prettier": "^2.5.1"
"prettier": "^2.5.1",
"chokidar": "^3.5.3"
},
"dependencies": {
"turbo": "1.2.9"

2
packages/app-store-cli/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
dist

View File

@ -0,0 +1,36 @@
{
"name": "@calcom/app-store-cli",
"version": "0.0.0",
"bin": "dist/cli.js",
"engines": {
"node": ">=10"
},
"scripts": {
"test": "ava",
"cli": "ts-node --transpile-only src/cli.tsx"
},
"files": [
"dist/cli.js"
],
"dependencies": {
"@calcom/lib": "*",
"ink": "^3.2.0",
"ink-select-input": "^4.2.1",
"ink-text-input": "^4.0.3",
"meow": "^9.0.0",
"react": "^17.0.2"
},
"devDependencies": {
"@ava/typescript": "^3.0.1",
"@types/react": "^18.0.9",
"ava": "^4.2.0",
"chalk": "^4.1.2",
"eslint-config-xo-react": "^0.27.0",
"eslint-plugin-react": "^7.30.0",
"eslint-plugin-react-hooks": "^4.5.0",
"ink-testing-library": "^2.1.0",
"ts-node": "^10.6.0",
"typescript": "^4.6.4",
"xo": "^0.39.1"
}
}

View File

@ -0,0 +1,38 @@
## Steps to create an app
- Create a folder in packages/app-store/{APP_NAME} = {APP}
- Fill it with a sample app
- Modify {APP}/_metadata.ts with the data provided
## Approach
- appType is derived from App Name(a slugify operation that makes a string that can be used as a director name, a variable name for imports and a URL path).
- appType is then used to create the app directory. It becomes `config.type` of config.json. config.type is the value used to create an entry in App table and retrieve any apps or credentials. It also becomes App.dirName
- dirnames that don't start with _ are considered apps in packages/app-store and based on those apps .generated.ts* files are created. This allows pre-cli apps to keep on working.
- app directory is populated with app-store/_baseApp with newly updated config.json and package.json
- `packages/prisma/seed-app-store.config.json` is updated with new app.
NOTE: After app-store-cli is live, Credential.appId and Credential.type would be same for new apps. For old apps they would remain different. Credential.type would be used to identify credentials in integrations call and Credential.appId/App.slug would be used to identify apps.
If we rename all existing apps to their slug names, we can remove type and then use just appId to refer to things everywhere. This can be done later on.
## TODO
- Beta Release
- Show a warning somewhere that app directory must not be renamed manually, edit command must be used.
- Improvements
- Prefill fields in edit command
- Merge app-store:watch and app-store commands, introduce app-store --watch
- Allow inputs in non interactive way as well - That would allow easily copy pasting commands.
- App already exists check. Ask user to run edit/regenerate command
- An app created through CLI should be able to completely skip API validation for testing purposes. Credentials should be created with no API specified specific to the app. It would allow us to test any app end to end not worrying about the corresponding API endpoint.
- Require assets path relative to app dir.
## Roadmap
- Someone can add wrong directory name(which doesn't satisfy slug requirements) manually. How to handle it.
- Allow editing and updating app from the cal app itself - including assets uploading when developing locally.
- Improvements in shared code across app
- Use baseApp/api/add.ts for all apps with configuration of credentials creation and redirection URL.
- Delete creation side effects if App creation fails - Might make debugging difficult
- This is so that web app doesn't break because of additional app folders or faulty db-seed

View File

@ -0,0 +1,314 @@
import child_process from "child_process";
import fs from "fs";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import SelectInput from "ink-select-input";
import TextInput from "ink-text-input";
import path from "path";
import React, { FC, useEffect, useRef, useState } from "react";
const slugify = (str: string) => {
// It is to be a valid dir name, a valid JS variable name and a valid URL path
return str.replace(/[^a-zA-Z0-9-]/g, "_").toLowerCase();
};
function getSlugFromAppName(appName: string | null): string | null {
if (!appName) {
return appName;
}
return slugify(appName);
}
function getAppDirPath(slug: any) {
return path.join(appStoreDir, `${slug}`);
}
const appStoreDir = path.resolve(__dirname, "..", "..", "app-store");
const workspaceDir = path.resolve(__dirname, "..", "..", "..");
const execSync = (...args) => {
const result = child_process.execSync(...args).toString();
if (process.env.DEBUG === "1") {
console.log(`$: ${args[0]}`);
console.log(result);
}
return args[0];
};
function absolutePath(appRelativePath) {
return path.join(appStoreDir, appRelativePath);
}
const updatePackageJson = ({ slug, appDescription, appDirPath }) => {
const packageJsonConfig = JSON.parse(fs.readFileSync(`${appDirPath}/package.json`).toString());
packageJsonConfig.name = `@calcom/${slug}`;
packageJsonConfig.description = appDescription;
// packageJsonConfig.description = `@calcom/${appName}`;
fs.writeFileSync(`${appDirPath}/package.json`, JSON.stringify(packageJsonConfig, null, 2));
};
const BaseAppFork = {
create: function* ({
appType,
editMode,
appDescription,
appName,
slug,
appTitle,
publisherName,
publisherEmail,
}) {
const appDirPath = getAppDirPath(slug);
let message = !editMode ? "Forking base app" : "Updating app";
yield message;
if (!editMode) {
execSync(`mkdir -p ${appDirPath}`);
execSync(`cp -r ${absolutePath("_baseApp/*")} ${appDirPath}`);
}
updatePackageJson({ slug, appDirPath, appDescription });
let config = {
"/*": "Don't modify slug - If required, do it using cli edit command",
name: appName,
title: appTitle,
// @deprecated - It shouldn't exist.
slug: slug,
imageSrc: `/api/app-store/${slug}/icon.svg`,
logo: `/api/app-store/${slug}/icon.svg`,
url: `https://cal.com/apps/${slug}`,
variant: appType,
publisher: publisherName,
email: publisherEmail,
description: appDescription,
};
const currentConfig = JSON.parse(fs.readFileSync(`${appDirPath}/config.json`).toString());
config = {
...currentConfig,
...config,
};
fs.writeFileSync(`${appDirPath}/config.json`, JSON.stringify(config, null, 2));
message = !editMode ? "Forked base app" : "Updated app";
yield message;
},
delete: function ({ slug }) {
const appDirPath = getAppDirPath(slug);
execSync(`rm -rf ${appDirPath}`);
},
};
const Seed = {
seedConfigPath: absolutePath("../prisma/seed-app-store.config.json"),
update: function ({ slug, appType, noDbUpdate }) {
const seedConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
if (!seedConfig.find((app) => app.slug === slug)) {
seedConfig.push({
dirName: slug,
categories: [appType],
slug: slug,
});
}
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`cd ${workspaceDir} && yarn db-seed`);
}
},
revert: async function ({ slug, noDbUpdate }) {
let seedConfig = JSON.parse(fs.readFileSync(this.seedConfigPath).toString());
seedConfig = seedConfig.filter((app) => app.slug !== slug);
fs.writeFileSync(this.seedConfigPath, JSON.stringify(seedConfig, null, 2));
if (!noDbUpdate) {
execSync(`yarn workspace @calcom/prisma delete-app ${slug}`);
}
},
};
const generateAppFiles = () => {
execSync(`cd ${appStoreDir} && node app-store.js`);
};
const CreateApp = ({ noDbUpdate, editMode = false }) => {
// AppName
// Type of App - Other, Calendar, Video, Payment, Messaging, Web3
const [appInputData, setAppInputData] = useState({});
const [inputIndex, setInputIndex] = useState(0);
const fields = [
{ label: "App Name", name: "appName", type: "text" },
{ label: "App Title", name: "appTitle", type: "text" },
{ label: "App Description", name: "appDescription", type: "text" },
{
label: "Type of App",
name: "appType",
type: "select",
options: [
{ label: "calendar", value: "calendar" },
{ label: "video", value: "video" },
{ label: "payment", value: "payment" },
{ label: "messaging", value: "messaging" },
{ label: "web3", value: "web3" },
{ label: "other", value: "other" },
],
},
{ label: "Publisher Name", name: "publisherName", type: "text" },
{ label: "Publisher Email", name: "publisherEmail", type: "text" },
];
const field = fields[inputIndex];
const fieldLabel = field?.label || "";
const fieldName = field?.name || "";
const fieldValue = appInputData[fieldName] || "";
const appName = appInputData["appName"];
const appType = appInputData["appType"];
const appTitle = appInputData["appTitle"];
const appDescription = appInputData["appDescription"];
const publisherName = appInputData["publisherName"];
const publisherEmail = appInputData["publisherEmail"];
const [result, setResult] = useState("...");
const slug = getSlugFromAppName(appName);
const allFieldsFilled = inputIndex === fields.length;
useEffect(() => {
// When all fields have been filled
if (allFieldsFilled) {
const it = BaseAppFork.create({
appType,
appDescription,
appName,
slug,
appTitle,
publisherName,
publisherEmail,
});
for (const item of it) {
setResult(item);
}
Seed.update({ slug, appType, noDbUpdate });
generateAppFiles();
// FIXME: Even after CLI showing this message, it is stuck doing work before exiting
// So we ask the user to wait for some time
setResult(
`App has been given slug: ${slug}. Just wait for a few seconds for the process to complete and start editing ${getAppDirPath(
slug
)} to work on your app.`
);
}
});
if (allFieldsFilled) {
return (
<>
<Text>
Creating app with name "{appName}" of type "{appType}"
</Text>
<Text>{result}</Text>
<Text>
Please note that you should use cli only to rename an app directory as it needs to be updated in DB
as well
</Text>
</>
);
}
// Hack: using field.name == "appTitle" to identify that app Name has been submitted and not being edited.
if (!editMode && field.name === "appTitle" && slug && fs.existsSync(getAppDirPath(slug))) {
return (
<>
<Text>App with slug {slug} already exists. If you want to edit it, use edit command</Text>
</>
);
}
return (
<Box>
<Text color="green">{`${fieldLabel}:`}</Text>
{field.type == "text" ? (
<TextInput
value={fieldValue}
onSubmit={(value) => {
if (!value) {
return;
}
setInputIndex((index) => {
return index + 1;
});
}}
onChange={(value) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: value,
};
});
}}
/>
) : (
<SelectInput<string>
items={field.options}
onSelect={(item) => {
setAppInputData((appInputData) => {
return {
...appInputData,
[fieldName]: item.value,
};
});
setInputIndex((index) => {
return index + 1;
});
}}></SelectInput>
)}
</Box>
);
};
const DeleteApp = ({ noDbUpdate, slug }) => {
const [confirmedAppSlug, setConfirmedAppSlug] = useState("");
const [allowDeletion, setAllowDeletion] = useState(false);
const [state, setState] = useState({});
useEffect(() => {
if (allowDeletion) {
BaseAppFork.delete({ slug });
Seed.revert({ slug });
generateAppFiles();
setState({ description: `App with slug ${slug} has been deleted`, done: true });
}
}, [allowDeletion, slug]);
return (
<>
<Text>
Confirm the slug of the app that you want to delete. Note, that it would cleanup the app directory,
App table and Credential table
</Text>
{!state.done && (
<TextInput
value={confirmedAppSlug}
onSubmit={(value) => {
if (value === slug) {
setState({ description: `Deletion started`, done: true });
setAllowDeletion(true);
} else {
setState({ description: `Slug doesn't match - Should have been ${slug}`, done: true });
}
}}
onChange={(val) => {
setConfirmedAppSlug(val);
}}></TextInput>
)}
<Text>{state.description}</Text>
</>
);
};
const App: FC<{ noDbUpdate?: boolean; command: "create" | "delete"; slug?: string }> = ({
command,
noDbUpdate,
slug,
}) => {
if (command === "create") {
return <CreateApp noDbUpdate={noDbUpdate} />;
}
if (command === "delete") {
return <DeleteApp slug={slug} noDbUpdate={noDbUpdate} />;
}
if (command === "edit") {
return <CreateApp editMode={true} noDbUpdate={noDbUpdate} />;
}
};
module.exports = App;
export default App;

View File

@ -0,0 +1,45 @@
#!/usr/bin/env node
import { render } from "ink";
import meow from "meow";
import React from "react";
import App from "./CliApp";
const cli = meow(
`
Usage
$ app-store create/delete
Options
--noDbUpdate Don't update DB. Just generate files.
`,
{
flags: {
noDbUpdate: {
type: "boolean",
},
slug: {
type: "string",
},
},
allowUnknownFlags: false,
}
);
if (cli.input.length !== 1) {
cli.showHelp();
}
const command = cli.input[0] as "create" | "delete" | "edit";
const supportedCommands = ["create", "delete", "edit"];
if (!supportedCommands.includes(command)) {
cli.showHelp();
}
let slug = null;
if (command === "delete") {
slug = cli.flags.slug;
}
render(<App slug={slug} command={command} noDbUpdate={cli.flags.noDbUpdate} />);

View File

@ -0,0 +1 @@
export const calRepoPrefix = "/Users/hariombalhara/www/cal.com/"

View File

@ -0,0 +1,18 @@
import test from "ava";
import chalk from "chalk";
import { render } from "ink-testing-library";
import React from "react";
import App from "./CliApp";
test("greet unknown user", (t) => {
const { lastFrame } = render(<App />);
t.is(lastFrame(), chalk`Hello, {green Stranger}`);
});
test("greet user with a name", (t) => {
const { lastFrame } = render(<App name="Jane" />);
t.is(lastFrame(), chalk`Hello, {green Jane}`);
});

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"module": "commonjs",
"jsx": "react",
"esModuleInterop": true,
"outDir": "dist",
"noEmitOnError": false,
"target": "ES2020",
"baseUrl": "."
},
"include": [
"next-env.d.ts",
"../../packages/types/*.d.ts",
"../../packages/types/next-auth.d.ts",
"./src/**/*.ts",
"./src/**/*.tsx",
"../lib/**/*.ts"
]
}

1
packages/app-store/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
*.generated.*

View File

@ -1,10 +1,10 @@
import prisma from "@calcom/prisma";
import { App } from "@calcom/types/App";
import { App, AppMeta } from "@calcom/types/App";
export async function getAppWithMetadata(app: { dirName: string }) {
let appMetadata: App | null = null;
let appMetadata: AppMeta | null = null;
try {
appMetadata = (await import(`./${app.dirName}/_metadata`)).default as App;
appMetadata = (await import(`./${app.dirName}/_metadata`)).default as AppMeta;
} catch (error) {
if (error instanceof Error) {
console.error(`No metadata found for: "${app.dirName}". Message:`, error.message);
@ -20,7 +20,8 @@ export async function getAppWithMetadata(app: { dirName: string }) {
/** Mainly to use in listings for the frontend, use in getStaticProps or getServerSideProps */
export async function getAppRegistry() {
const dbApps = await prisma.app.findMany({ select: { dirName: true, slug: true, categories: true } });
const apps = [] as Omit<App, "key">[];
const apps = [] as App[];
const a: App = null;
for await (const dbapp of dbApps) {
const app = await getAppWithMetadata(dbapp);
if (!app) continue;
@ -29,6 +30,7 @@ export async function getAppRegistry() {
// if (!app.installed) return apps;
apps.push({
...app,
...dbapp,
installed:
true /* All apps from DB are considered installed by default. @TODO: Add and filter our by `enabled` property */,
});

View File

@ -0,0 +1,9 @@
# Base App - App Store CLI
It contains the boiler plate code for a new app. There is a one time copying of files right now.
You can read details of how exactly the CLI uses this base app [here](../../app-store-cli/README.md).
## TODO
- Rename it _baseApp to convey very clearly that it is not an actual app.

View File

@ -0,0 +1 @@
Edit README.mdx file to update it.

View File

@ -0,0 +1,20 @@
import type { AppMeta } from "@calcom/types/App";
import config from "./config.json";
import _package from "./package.json";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
export const metadata = {
description: _package.description,
category: "other",
// FIXME: Currently for an app to be shown as installed, it must have this variable set. Either hardcoded or if it depends on some env variable, that should be checked here
installed: true,
rating: 0,
reviews: 0,
trending: true,
verified: true,
...config,
} as AppMeta;
export default metadata;

View File

@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
import appConfig from "../config.json";
// TODO: There is a lot of code here that would be used by almost all apps
// - Login Validation
// - Looking up credential.
// - Creating credential would be specific to app, so there can be just createCredential method that app can expose
// - Redirection after successful installation can also be configured by app
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session?.user?.id) {
return res.status(401).json({ message: "You must be logged in to do this" });
}
// TODO: Define appType once and import everywhere
const slug = appConfig.slug;
try {
const alreadyInstalled = await prisma.credential.findFirst({
where: {
appId: slug,
userId: req.session.user.id,
},
});
if (alreadyInstalled) {
throw new Error("Already installed");
}
const installation = await prisma.credential.create({
data: {
// TODO: Why do we need type in Credential? Why can't we simply use appId
// Using slug as type for new credentials so that we keep on using type in requests.
// `deriveAppKeyFromSlug` should be able to handle old type and new type which is equal to slug
type: slug,
key: {},
userId: req.session.user.id,
appId: slug,
},
});
if (!installation) {
throw new Error(`Unable to create user credential for ${slug}`);
}
} catch (error: unknown) {
if (error instanceof Error) {
console.error(error.message);
return res.status(500).json({ message: error.message });
}
return res.status(500);
}
return res.status(200).json({ url: "/apps/installed" });
}

View File

@ -0,0 +1 @@
export { default as add } from "./add";

View File

@ -0,0 +1,19 @@
import type { InstallAppButtonProps } from "@calcom/app-store/types";
import useAddAppMutation from "../../_utils/useAddAppMutation";
import appConfig from "../config.json";
export default function InstallAppButton(props: InstallAppButtonProps) {
const mutation = useAddAppMutation(appConfig.slug);
return (
<>
{props.render({
onClick() {
mutation.mutate("");
},
loading: mutation.isLoading,
})}
</>
);
}

View File

@ -0,0 +1,15 @@
export default function Icon() {
return (
<svg
width="40"
height="40"
viewBox="0 0 256 256"
xmlns="http://www.w3.org/2000/svg"
preserveAspectRatio="xMidYMid">
<path
d="M159.999 128.056a76.55 76.55 0 0 1-4.915 27.024 76.745 76.745 0 0 1-27.032 4.923h-.108c-9.508-.012-18.618-1.75-27.024-4.919A76.557 76.557 0 0 1 96 128.056v-.112a76.598 76.598 0 0 1 4.91-27.02A76.492 76.492 0 0 1 127.945 96h.108a76.475 76.475 0 0 1 27.032 4.923 76.51 76.51 0 0 1 4.915 27.02v.112zm94.223-21.389h-74.716l52.829-52.833a128.518 128.518 0 0 0-13.828-16.349v-.004a129 129 0 0 0-16.345-13.816l-52.833 52.833V1.782A128.606 128.606 0 0 0 128.064 0h-.132c-7.248.004-14.347.62-21.265 1.782v74.716L53.834 23.665A127.82 127.82 0 0 0 37.497 37.49l-.028.02A128.803 128.803 0 0 0 23.66 53.834l52.837 52.833H1.782S0 120.7 0 127.956v.088c0 7.256.615 14.367 1.782 21.289h74.716l-52.837 52.833a128.91 128.91 0 0 0 30.173 30.173l52.833-52.837v74.72a129.3 129.3 0 0 0 21.24 1.778h.181a129.15 129.15 0 0 0 21.24-1.778v-74.72l52.838 52.837a128.994 128.994 0 0 0 16.341-13.82l.012-.012a129.245 129.245 0 0 0 13.816-16.341l-52.837-52.833h74.724c1.163-6.91 1.77-14 1.778-21.24v-.186c-.008-7.24-.615-14.33-1.778-21.24z"
fill="#FF4A00"
/>
</svg>
);
}

View File

@ -0,0 +1,2 @@
export { default as InstallAppButton } from "./InstallAppButton";
export { default as Icon } from "./icon";

View File

@ -0,0 +1 @@
{}

View File

@ -0,0 +1,3 @@
export * as api from "./api";
export * as components from "./components";
export { metadata } from "./_metadata";

View File

@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/cli_base__app_name",
"version": "0.0.0",
"main": "./index.ts",
"description": "Your app description goes here.",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}

View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="utf-8"?><svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 122.88 73.7" style="enable-background:new 0 0 122.88 73.7" xml:space="preserve"><style type="text/css">.st0{fill-rule:evenodd;clip-rule:evenodd;}</style><g><path class="st0" d="M8.34,0h106.2c4.59,0,8.34,3.77,8.34,8.34v57.02c0,4.56-3.77,8.34-8.34,8.34H8.34C3.77,73.7,0,69.95,0,65.36 V8.34C0,3.75,3.75,0,8.34,0L8.34,0z M18.5,21.47h5.98c3.86,0,6.48,0.18,7.84,0.53c1.36,0.35,2.4,0.93,3.11,1.74 c0.71,0.81,1.16,1.72,1.33,2.71c0.18,0.99,0.26,2.95,0.26,5.86v10.78c0,2.76-0.13,4.6-0.4,5.53c-0.26,0.93-0.71,1.66-1.36,2.18 c-0.64,0.53-1.44,0.89-2.39,1.11c-0.95,0.21-2.38,0.31-4.3,0.31H18.5V21.47L18.5,21.47z M26.49,26.73v20.23 c1.16,0,1.87-0.23,2.13-0.69c0.27-0.46,0.4-1.71,0.4-3.78V30.55c0-1.39-0.04-2.29-0.12-2.68c-0.1-0.39-0.29-0.67-0.61-0.86 C27.96,26.82,27.37,26.73,26.49,26.73L26.49,26.73z M40.68,21.47h13.34v6.16h-5.34v5.83h5v5.86h-5v6.77h5.87v6.15H40.68V21.47 L40.68,21.47z M82.22,21.47v30.76h-6.99V31.47l-2.79,20.76h-4.96l-2.95-20.29v20.29h-6.99V21.47h10.36 c0.31,1.85,0.62,4.03,0.97,6.54l1.1,7.83l1.83-14.37H82.22L82.22,21.47z M104.38,39.48c0,3.09-0.07,5.28-0.21,6.56 c-0.15,1.29-0.6,2.46-1.36,3.53c-0.77,1.06-1.8,1.88-3.11,2.45c-1.31,0.57-2.83,0.86-4.56,0.86c-1.65,0-3.13-0.27-4.44-0.81 c-1.32-0.54-2.37-1.34-3.17-2.42c-0.8-1.08-1.28-2.25-1.43-3.51c-0.15-1.27-0.23-3.48-0.23-6.66v-5.26c0-3.09,0.07-5.28,0.23-6.58 c0.14-1.28,0.59-2.46,1.36-3.52c0.77-1.06,1.8-1.88,3.11-2.45c1.3-0.57,2.82-0.86,4.56-0.86c1.65,0,3.12,0.27,4.43,0.81 c1.31,0.54,2.37,1.35,3.17,2.42c0.79,1.08,1.27,2.25,1.42,3.52c0.15,1.26,0.23,3.48,0.23,6.66V39.48L104.38,39.48z M96.39,29.38 c0-1.44-0.08-2.35-0.24-2.75c-0.15-0.4-0.48-0.6-0.98-0.6c-0.42,0-0.74,0.16-0.96,0.49c-0.23,0.32-0.34,1.27-0.34,2.86v14.35 c0,1.8,0.07,2.9,0.22,3.31c0.15,0.42,0.49,0.62,1.02,0.62c0.55,0,0.89-0.23,1.05-0.72c0.15-0.48,0.23-1.63,0.23-3.45V29.38 L96.39,29.38z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -21,6 +21,6 @@ export const metadata = {
variant: "conferencing",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -2,10 +2,12 @@ import { useMutation } from "react-query";
import type { IntegrationOAuthCallbackState } from "@calcom/app-store/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { App } from "@calcom/types/App";
import { AppMeta } from "@calcom/types/App";
function useAddAppMutation(type: App["type"], options?: Parameters<typeof useMutation>[2]) {
const appName = type.replace(/_/g, "");
function useAddAppMutation(type: AppMeta["type"], options?: Parameters<typeof useMutation>[2]) {
// FIXME: Ensure that existing apps keep on working
// const appName = type.replace(/_/g, "");
const appName = type;
const mutation = useMutation(async () => {
const state: IntegrationOAuthCallbackState = {
returnTo: WEBAPP_URL + "/apps/installed" + location.search,

View File

@ -1,24 +0,0 @@
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"),
vital: import("./vital/api"),
zoomvideo: import("@calcom/zoomvideo/api"),
office365video: import("@calcom/office365video/api"),
wipemycalother: import("./wipemycalother/api"),
jitsivideo: import("./jitsivideo/api"),
huddle01video: import("./huddle01video/api"),
metamask: import("./metamask/api"),
giphy: import("./giphy/api"),
spacebookingother: import("./spacebooking/api"),
// @todo Until we use DB slugs everywhere
zapierother: import("./zapier/api"),
};
export default apiHandlers;

View File

@ -0,0 +1,123 @@
const fs = require("fs");
const path = require("path");
let isInWatchMode = false;
if (process.argv[2] === "--watch") {
isInWatchMode = true;
}
const chokidar = require("chokidar");
const { debounce } = require("lodash");
function getAppName(candidatePath) {
function isValidAppName(candidatePath) {
if (!candidatePath.startsWith("_") && !candidatePath.includes("/") && !candidatePath.includes("\\")) {
return candidatePath;
}
}
if (isValidAppName(candidatePath)) {
// Already a dirname of an app
return candidatePath;
}
// Get dirname of app from full path
const dirName = path.relative(__dirname, candidatePath);
return isValidAppName(dirName) ? dirName : null;
}
function generateFiles() {
let clientOutput = [`import dynamic from "next/dynamic"`];
let serverOutput = [];
const appDirs = [];
fs.readdirSync(`${__dirname}`).forEach(function (dir) {
if (fs.statSync(`${__dirname}/${dir}`).isDirectory()) {
if (!getAppName(dir)) {
return;
}
appDirs.push(dir);
}
});
function forEachAppDir(callback) {
for (let i = 0; i < appDirs.length; i++) {
callback(appDirs[i]);
}
}
function getObjectExporter(objectName, { fileToBeImported, importBuilder, entryBuilder }) {
const output = [];
forEachAppDir((dirName) => {
if (fs.existsSync(path.join(dirName, fileToBeImported))) {
output.push(importBuilder(dirName));
}
});
output.push(`export const ${objectName} = {`);
forEachAppDir((dirName) => {
if (fs.existsSync(path.join(dirName, fileToBeImported))) {
output.push(entryBuilder(dirName));
}
});
output.push(`};`);
return output;
}
serverOutput.push(
...getObjectExporter("appStoreMetadata", {
fileToBeImported: "_metadata.ts",
importBuilder: (dirName) => `import { metadata as ${dirName}_meta } from "./${dirName}/_metadata";`,
entryBuilder: (dirName) => `${dirName}:${dirName}_meta,`,
})
);
serverOutput.push(
...getObjectExporter("apiHandlers", {
fileToBeImported: "api/index.ts",
importBuilder: (dirName) => `const ${dirName}_api = import("./${dirName}/api");`,
entryBuilder: (dirName) => `${dirName}:${dirName}_api,`,
})
);
clientOutput.push(
...getObjectExporter("InstallAppButtonMap", {
fileToBeImported: "components/InstallAppButton.tsx",
importBuilder: (dirName) =>
`const ${dirName}_installAppButton = dynamic(() =>import("./${dirName}/components/InstallAppButton"));`,
entryBuilder: (dirName) => `${dirName}:${dirName}_installAppButton,`,
})
);
fs.writeFileSync(`${__dirname}/apps.generated.ts`, serverOutput.join("\n"));
fs.writeFileSync(`${__dirname}/apps.components.generated.tsx`, clientOutput.join("\n"));
console.log("Generated `apps.generated.ts` and `apps.components.generated.tsx`");
}
const debouncedGenerateFiles = debounce(generateFiles);
if (isInWatchMode) {
chokidar
.watch(__dirname)
.on("addDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Added ${appName}`);
debouncedGenerateFiles();
}
})
.on("change", (filePath) => {
if (filePath.endsWith("config.json")) {
console.log("Config file changed");
debouncedGenerateFiles();
}
})
.on("unlinkDir", (dirPath) => {
const appName = getAppName(dirPath);
if (appName) {
console.log(`Removed ${appName}`);
debouncedGenerateFiles();
}
});
} else {
generateFiles();
}

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -20,6 +20,6 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -20,6 +20,6 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -20,7 +20,7 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export * as api from "./api";
export * as components from "./components";

View File

@ -1,50 +1,23 @@
import { useSession } from "next-auth/react";
import dynamic from "next/dynamic";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { deriveAppKeyFromSlugOrType } from "@calcom/lib/deriveAppKeyFromSlugOrType";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import Button from "@calcom/ui/Button";
import { InstallAppButtonMap } from "./apps.components.generated";
import { InstallAppButtonProps } from "./types";
export const InstallAppButtonMap = {
// examplevideo: dynamic(() => import("./_example/components/InstallAppButton")),
applecalendar: dynamic(() => import("./applecalendar/components/InstallAppButton")),
caldavcalendar: dynamic(() => import("./caldavcalendar/components/InstallAppButton")),
googlecalendar: dynamic(() => import("./googlecalendar/components/InstallAppButton")),
hubspotothercalendar: dynamic(() => import("./hubspotothercalendar/components/InstallAppButton")),
office365calendar: dynamic(() => import("./office365calendar/components/InstallAppButton")),
slackmessaging: dynamic(() => import("./slackmessaging/components/InstallAppButton")),
stripepayment: dynamic(() => import("./stripepayment/components/InstallAppButton")),
tandemvideo: dynamic(() => import("./tandemvideo/components/InstallAppButton")),
zoomvideo: dynamic(() => import("./zoomvideo/components/InstallAppButton")),
office365video: dynamic(() => import("./office365video/components/InstallAppButton")),
wipemycalother: dynamic(() => import("./wipemycalother/components/InstallAppButton")),
zapier: dynamic(() => import("./zapier/components/InstallAppButton")),
jitsivideo: dynamic(() => import("./jitsivideo/components/InstallAppButton")),
huddle01video: dynamic(() => import("./huddle01video/components/InstallAppButton")),
metamask: dynamic(() => import("./metamask/components/InstallAppButton")),
giphy: dynamic(() => import("./giphy/components/InstallAppButton")),
spacebookingother: dynamic(() => import("./spacebooking/components/InstallAppButton")),
vital: dynamic(() => import("./vital/components/InstallAppButton")),
};
export const InstallAppButton = (
props: {
type: App["type"];
slug: AppMeta["slug"];
} & InstallAppButtonProps
) => {
const { status } = useSession();
const { t } = useLocale();
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];
}
const key = deriveAppKeyFromSlugOrType(props.slug, InstallAppButtonMap);
const InstallAppButtonComponent = InstallAppButtonMap[key as keyof typeof InstallAppButtonMap];
if (!InstallAppButtonComponent) return null;
if (status === "unauthenticated")
return (

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -8,6 +8,8 @@ export const metadata = {
description: _package.description,
installed: !!process.env.DAILY_API_KEY,
type: "daily_video",
//TODO: Remove imports in apps.generated.tsx for uninstallable apps
uninstallable: true,
imageSrc: "/api/app-store/dailyvideo/icon.svg",
variant: "conferencing",
url: "https://daily.co",
@ -25,6 +27,6 @@ export const metadata = {
locationType: LocationType.Daily,
locationLabel: "Cal Video",
key: { apikey: process.env.DAILY_API_KEY },
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,10 +1,11 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "Giphy",
description: _package.description,
installed: true,
category: "other",
// If using static next public folder, can then be referenced from the base URL (/).
imageSrc: "/api/app-store/giphy/icon.svg",
@ -20,6 +21,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

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

View File

@ -1,5 +1,5 @@
import { validJson } from "@calcom/lib/jsonUtils";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -7,6 +7,7 @@ import _package from "./package.json";
export const metadata = {
name: "Google Calendar",
description: _package.description,
uninstallable: true,
installed: !!(process.env.GOOGLE_API_CREDENTIALS && validJson(process.env.GOOGLE_API_CREDENTIALS)),
type: "google_calendar",
title: "Google Calendar",
@ -24,6 +25,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.GoogleMeet,
locationLabel: "Google Meet",
} as App;
} as AppMeta;
export default metadata;

View File

@ -33,10 +33,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
let key = "";
if (code) {
const token = await oAuth2Client.getToken(code);
// TEMPORARY: REMOVE IT
try {
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
key = token.res?.data;
}
} catch (e) {
console.log(e);
}
await prisma.credential.create({

View File

@ -1,5 +1,5 @@
import { validJson } from "@calcom/lib/jsonUtils";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -25,6 +25,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.GoogleMeet,
locationLabel: "Google Meet",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,9 +1,10 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
export const metadata = {
name: "HubSpot CRM",
installed: !!process.env.HUBSPOT_CLIENT_ID,
description: _package.description,
type: "hubspot_other_calendar",
imageSrc: "/api/app-store/hubspotothercalendar/icon.svg",
@ -20,6 +21,6 @@ export const metadata = {
title: "HubSpot CRM",
trending: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,5 +1,5 @@
import { randomString } from "@calcom/lib/random";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -26,6 +26,6 @@ export const metadata = {
locationType: LocationType.Huddle01,
locationLabel: "Huddle01 Video",
key: { apikey: randomString(12) },
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -24,6 +24,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.Jitsi,
locationLabel: "Jitsi Video",
} as App;
} as AppMeta;
export default metadata;

View File

@ -39,5 +39,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
return res.status(500);
}
return res.redirect("/apps/installed");
return res.status(200).json({ url: "/apps/installed" });
}

View File

@ -1,45 +0,0 @@
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 "./giphy/_metadata";
import { metadata as googlecalendar } from "./googlecalendar/_metadata";
import { metadata as googlevideo } from "./googlevideo/_metadata";
import { metadata as hubspotothercalendar } from "./hubspotothercalendar/_metadata";
import { metadata as huddle01video } from "./huddle01video/_metadata";
import { metadata as jitsivideo } from "./jitsivideo/_metadata";
import { metadata as metamask } from "./metamask/_metadata";
import { metadata as office365calendar } from "./office365calendar/_metadata";
import { metadata as office365video } from "./office365video/_metadata";
import { metadata as slackmessaging } from "./slackmessaging/_metadata";
import { metadata as spacebooking } from "./spacebooking/_metadata";
import { metadata as stripepayment } from "./stripepayment/_metadata";
import { metadata as tandemvideo } from "./tandemvideo/_metadata";
import { metadata as vital } from "./vital/_metadata";
import { metadata as wipemycalother } from "./wipemycalother/_metadata";
import { metadata as zapier } from "./zapier/_metadata";
import { metadata as zoomvideo } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
applecalendar,
caldavcalendar,
dailyvideo,
googlecalendar,
googlevideo,
hubspotothercalendar,
huddle01video,
jitsivideo,
office365calendar,
office365video,
slackmessaging,
stripepayment,
spacebooking,
tandemvideo,
vital,
zoomvideo,
wipemycalother,
metamask,
giphy,
zapier,
};
export default appStoreMetadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -21,6 +21,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -35,5 +35,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
return res.status(500);
}
return res.redirect("/apps/installed");
return res.status(200).json({ url: "/apps/installed" });
}

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -19,6 +19,6 @@ export const metadata = {
url: "https://cal.com/",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -22,6 +22,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.Teams,
locationLabel: "MS Teams",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -14,11 +14,10 @@ export const metadata = {
slug: "slack",
title: "Slack App",
trending: true,
type: "slack_messaging",
url: "https://slack.com/",
variant: "conferencing",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -2,9 +2,9 @@
width="640"
height="360"
src="https://www.loom.com/embed/f8d2cd9b2ac74f0c916f20c4441bd1da"
frameborder="0"
webkitallowfullscreen
mozallowfullscreen
allowfullscreen></iframe>
frameBorder="0"
webkitallowfullscreen="true"
mozallowfullscreen="true"
allowFullScreen></iframe>
Looking to honor May 4th? Search no further. Download this app to make your booking success page resemble a long time ago in a galaxy far far away.

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -21,6 +21,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -25,6 +25,6 @@ export const metadata = {
variant: "payment",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -23,6 +23,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.Tandem,
locationLabel: "Tandem Video",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,23 +1,24 @@
import { Prisma } from "@prisma/client";
import { TFunction } from "next-i18next";
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "./locations";
import { getAppRegistry } from "./_appRegistry";
// If you import this file on any app it should produce circular dependency
// import appStore from "./index";
import { appStoreMetadata } from "./metadata";
import { appStoreMetadata } from "./apps.generated";
import { LocationType } from "./locations";
const ALL_APPS_MAP = Object.keys(appStoreMetadata).reduce((store, key) => {
store[key] = appStoreMetadata[key as keyof typeof appStoreMetadata];
return store;
}, {} as Record<string, App>);
}, {} as Record<string, AppMeta>);
const credentialData = Prisma.validator<Prisma.CredentialArgs>()({
select: { id: true, type: true, key: true, userId: true, appId: true },
});
type CredentialData = Prisma.CredentialGetPayload<typeof credentialData>;
type CredentialData = Omit<Prisma.CredentialGetPayload<typeof credentialData>, "type">;
export const ALL_APPS = Object.values(ALL_APPS_MAP);
@ -55,34 +56,35 @@ export function getLocationOptions(integrations: AppMeta, t: TFunction) {
* This should get all available apps to the user based on his saved
* credentials, this should also get globally available apps.
*/
function getApps(userCredentials: CredentialData[]) {
const apps = ALL_APPS.map((appMeta) => {
const credentials = userCredentials.filter((credential) => credential.type === appMeta.type);
async function getApps(userCredentials: CredentialData[]) {
const appsWithMeta = await getAppRegistry();
const apps = appsWithMeta.map((app) => {
const credentials = userCredentials.filter((credential) => credential.appId === app.slug);
let locationOption: OptionTypeBase | null = null;
/** If the app is a globally installed one, let's inject it's key */
if (appMeta.isGlobal) {
if (app.isGlobal) {
credentials.push({
id: +new Date().getTime(),
type: appMeta.type,
key: appMeta.key!,
key: app.key!,
userId: +new Date().getTime(),
appId: appMeta.slug,
appId: app.slug,
});
}
/** Check if app has location option AND add it if user has credentials for it */
if (credentials.length > 0 && appMeta?.locationType) {
if (credentials.length > 0 && app?.locationType) {
locationOption = {
value: appMeta.locationType,
label: appMeta.locationLabel || "No label set",
value: app.locationType,
label: app.locationLabel || "No label set",
disabled: false,
};
}
const credential: typeof credentials[number] | null = credentials[0] || null;
return {
...appMeta,
...app,
appId: app.slug,
/**
* @deprecated use `credentials`
*/
@ -96,12 +98,6 @@ function getApps(userCredentials: CredentialData[]) {
return apps;
}
export type AppMeta = ReturnType<typeof getApps>;
export function hasIntegrationInstalled(type: App["type"]): boolean {
return ALL_APPS.some((app) => app.type === type && !!app.installed);
}
export function getLocationTypes(): string[] {
return ALL_APPS.reduce((locations, app) => {
if (typeof app.locationType === "string") {
@ -113,13 +109,13 @@ export function getLocationTypes(): string[] {
export function getLocationLabels(t: TFunction) {
const defaultLocationLabels = defaultLocations.reduce((locations, location) => {
if(location.label === "attendee_phone_number") {
locations[location.value] = t("your_number")
return locations
if (location.label === "attendee_phone_number") {
locations[location.value] = t("your_number");
return locations;
}
if(location.label === "host_phone_number") {
locations[location.value] = `${t("phone_call")} (${t("number_provided")})`
return locations
if (location.label === "host_phone_number") {
locations[location.value] = `${t("phone_call")} (${t("number_provided")})`;
return locations;
}
locations[location.value] = t(location.label);
return locations;
@ -137,16 +133,4 @@ export function getAppName(name: string): string | null {
return ALL_APPS_MAP[name as keyof typeof ALL_APPS_MAP]?.name ?? null;
}
export function getAppType(name: string): string {
const type = ALL_APPS_MAP[name as keyof typeof ALL_APPS_MAP].type;
if (type.endsWith("_calendar")) {
return "Calendar";
}
if (type.endsWith("_payment")) {
return "Payment";
}
return "Unknown";
}
export default getApps;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -22,6 +22,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "support@tryvital.io",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -21,6 +21,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import _package from "./package.json";
@ -20,6 +20,6 @@ export const metadata = {
variant: "other",
verified: true,
email: "help@cal.com",
} as App;
} as AppMeta;
export default metadata;

View File

@ -6,6 +6,7 @@ import { Toaster } from "react-hot-toast";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import showToast from "@calcom/lib/notification";
import { App } from "@calcom/prisma/client";
import { Button, Loader, Tooltip } from "@calcom/ui";
/** TODO: Maybe extract this into a package to prevent circular dependencies */
@ -29,7 +30,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
const deleteApiKey = trpc.useMutation("viewer.apiKeys.delete");
const zapierCredentials: { credentialIds: number[] } | undefined = integrations.data?.items.find(
(item: { type: string }) => item.type === "zapier_other"
(item: { appId: App["slug"] }) => item.appId === ZAPIER
);
const [credentialId] = zapierCredentials?.credentialIds || [false];
const showContent = integrations.data && integrations.isSuccess && credentialId;
@ -49,7 +50,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
if (integrations.isLoading) {
return (
<div className="flex absolute z-50 h-screen w-full items-center bg-gray-200">
<div className="absolute z-50 flex h-screen w-full items-center bg-gray-200">
<Loader />
</div>
);
@ -75,7 +76,7 @@ export default function ZapierSetup(props: IZapierSetupProps) {
) : (
<>
<div className="mt-1 text-xl">{t("your_unique_api_key")}</div>
<div className="flex my-2 mt-3">
<div className="my-2 mt-3 flex">
<div className="mr-1 w-full rounded bg-gray-100 p-3 pr-5">{newApiKey}</div>
<Tooltip content="copy to clipboard">
<Button

View File

@ -1,4 +1,4 @@
import type { App } from "@calcom/types/App";
import type { AppMeta } from "@calcom/types/App";
import { LocationType } from "../locations";
import _package from "./package.json";
@ -22,6 +22,6 @@ export const metadata = {
email: "help@cal.com",
locationType: LocationType.Zoom,
locationLabel: "Zoom Video",
} as App;
} as AppMeta;
export default metadata;

View File

@ -17,7 +17,9 @@ export { getCalendar };
export const getCalendarCredentials = (credentials: Array<Credential>, userId: number) => {
const calendarCredentials = getApps(credentials)
.filter((app) => app.type.endsWith("_calendar"))
.filter((app) => {
return app.category.includes("calendar");
})
.flatMap((app) => {
const credentials = app.credentials.flatMap((credential) => {
const calendar = getCalendar(credential);

View File

@ -110,8 +110,8 @@ export default class EventManager {
*/
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"));
this.calendarCredentials = appCredentials.filter((cred) => cred.app.category.includes("calendar"));
this.videoCredentials = appCredentials.filter((cred) => cred.app.category.includes("video"));
}
/**

View File

@ -0,0 +1,36 @@
export function deriveAppKeyFromSlugOrType(slugOrType, map) {
const oldTypes = ["video", "other", "calendar", "web3", "payment", "messaging"];
// slack has a bug where in config slack_app is the type(to match Credential.type) but directory is `slackmessaging`
// We can't derive slackmessaging from slack_app without hardcoding it.
if (slugOrType === "slack_app") {
return "slackmessaging";
}
let handlers = map[slugOrType];
if (handlers) {
return slugOrType;
}
// There can be two types of legacy types
// - zoom_video
// - zoomvideo
// Transform `zoom_video` to `zoomvideo`;
slugOrType = slugOrType.split("_").join("");
handlers = map[slugOrType];
if (handlers) {
return slugOrType;
}
// Instead of doing a blind split at _ and using the first part, apply this hack only on strings that match legacy type.
// Transform zoomvideo to zoom
oldTypes.some((type) => {
const matcher = new RegExp(`(.+)${type}$`);
if (slugOrType.match(matcher)) {
slugOrType = slugOrType.replace(matcher, "$1");
return true;
}
});
return slugOrType;
}

View File

@ -1,5 +1,5 @@
export const slugify = (str: string) => {
return str.replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase();
return str.replace(/[^a-zA-Z0-9-]/g, "_").toLowerCase();
};
export default slugify;

View File

@ -0,0 +1,36 @@
import prisma from ".";
require("dotenv").config({ path: "../../.env" });
// TODO: Put some restrictions here to run it on local DB only.
// Production DB currently doesn't support app deletion
async function main() {
const appId = process.argv[2];
try {
await prisma.app.delete({
where: {
slug: appId,
},
});
await prisma.credential.deleteMany({
where: {
appId: appId,
},
});
console.log(`Deleted app from DB: '${appId}'`);
} catch (e) {
if (e.code === "P2025") {
console.log(`App '${appId}' already deleted from DB`);
return;
}
throw e;
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -16,7 +16,8 @@
"dx": "yarn db-setup",
"generate-schemas": "prisma generate && prisma format",
"post-install": "yarn generate-schemas",
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts"
"seed-app-store": "ts-node --transpile-only ./seed-app-store.ts",
"delete-app": "ts-node --transpile-only ./delete-app.ts"
},
"devDependencies": {
"npm-run-all": "^4.1.5",

View File

@ -81,11 +81,13 @@ model EventType {
model Credential {
id Int @id @default(autoincrement())
// To be dropped, it is not being used.
type String
key Json
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
userId Int?
app App? @relation(fields: [appId], references: [slug], onDelete: Cascade)
// How to make it a required column?
appId String?
}

View File

@ -0,0 +1 @@
[]

View File

@ -1,4 +1,6 @@
import { Prisma } from "@prisma/client";
import fs from "fs";
import path from "path";
import prisma from ".";
@ -20,7 +22,7 @@ async function createApp(
update: { dirName, categories, keys },
});
await prisma.credential.updateMany({
where: { type },
where: { appId: slug },
data: { appId: slug },
});
console.log(`📲 Upserted app: '${slug}'`);
@ -131,6 +133,14 @@ async function main() {
webhook_secret: process.env.STRIPE_WEBHOOK_SECRET,
});
}
const generatedApps = JSON.parse(
fs.readFileSync(path.join(__dirname, "seed-app-store.config.json"), "utf8")
);
for (let i = 0; i < generatedApps.length; i++) {
const generatedApp = generatedApps[i];
await createApp(generatedApp.slug, generatedApp.dirName, generatedApp.categories, generatedApp.type);
}
}
main()

View File

@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { App as AppFromPrisma, Prisma } from "@prisma/client";
import type { LocationType } from "@calcom/app-store/locations";
@ -6,22 +6,13 @@ import type { LocationType } from "@calcom/app-store/locations";
* This is the definition for an app store's app metadata.
* This is used to display App info, categorize or hide certain apps in the app store.
*/
export interface App {
export interface AppMeta {
/**
* @deprecated
* Wheter if the app is installed or not. Usually we check for api keys in env
* variables to determine if this is true or not.
* */
installed?: boolean;
/** The app type */
type:
| `${string}_calendar`
| `${string}_messaging`
| `${string}_payment`
| `${string}_video`
| `${string}_web3`
| `${string}_other`
| `${string}_other_calendar`;
/** The display name for the app, TODO settle between this or name */
title: string;
/** The display name for the app */
@ -65,8 +56,6 @@ export interface App {
locationLabel?: string;
/** Needed API Keys (usually for global apps) */
key?: Prisma.JsonValue;
/** Needed API Keys (usually for global apps) */
key?: Prisma.JsonValue;
/** If not free, what kind of fees does the app have */
feeType?: "monthly" | "usage-based" | "one-time" | "free";
/** 0 = free. if type="usage-based" it's the price per booking */
@ -74,3 +63,5 @@ export interface App {
/** only required for "usage-based" billing. % of commission for paid bookings */
commission?: number;
}
export type App = AppMeta | AppFromPrisma;

2060
yarn.lock

File diff suppressed because it is too large Load Diff